diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..de3660d --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/devcontainers/base:trixie + +ENV PIP_BREAK_SYSTEM_PACKAGES=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + micropython \ + python3-pip \ + curl \ + ca-certificates \ + && pip3 install --no-cache-dir hatch \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js 24 LTS via nvm and enable Yarn globally through Corepack. +RUN su - vscode -c 'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \ + && . "$HOME/.nvm/nvm.sh" \ + && nvm install 24 \ + && corepack enable yarn' diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ede1e08 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "name": "Rinkhals WebUI", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "remoteUser": "vscode", + "postCreateCommand": ".devcontainer/post-create.sh ${containerWorkspaceFolder}", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "tamasfe.even-better-toml", + "svelte.svelte-vscode" + ], + "settings": { + "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" + } + } + } +} diff --git a/.devcontainer/install_micropython_lib.py b/.devcontainer/install_micropython_lib.py new file mode 100644 index 0000000..dbc7a68 --- /dev/null +++ b/.devcontainer/install_micropython_lib.py @@ -0,0 +1,90 @@ +#!/usr/bin/env micropython +"""Install MicroPython packages via mip.""" + +import mip + +PACKAGES = [ + "__future__", + "abc", + "argparse", + "base64", + "binascii", + "bisect", + "cmd", + "collections", + "collections-defaultdict", + "contextlib", + "copy", + "curses.ascii", + "datetime", + "errno", + "fnmatch", + "functools", + "gzip", + "hashlib", + "hashlib-core", + "hashlib-sha224", + "hashlib-sha256", + "hashlib-sha384", + "hashlib-sha512", + "heapq", + "hmac", + "html", + "inspect", + "io", + "itertools", + "keyword", + "locale", + "logging", + "operator", + "os", + "os-path", + "pathlib", + "pickle", + "pkg_resources", + "pkgutil", + "pprint", + "quopri", + "random", + "shutil", + "ssl", + "stat", + "string", + "struct", + "tarfile", + "tarfile-write", + "tempfile", + "textwrap", + "threading", + "time", + "traceback", + "types", + "unittest", + "unittest-discover", + "uu", + "venv", + "warnings", + "zlib", + "github:josverl/micropython-stubs/mip/typing.json", +] + + +def install_all(): + failures = [] + for package in PACKAGES: + print("Installing {}".format(package)) + try: + mip.install(package) + except Exception as exc: + failures.append((package, exc)) + print("Failed installing {}: {}".format(package, exc)) + if failures: + print("Completed with failures:") + for package, exc in failures: + print(" {}: {}".format(package, exc)) + raise SystemExit(1) + print("All packages installed successfully.") + + +if __name__ == "__main__": + install_all() diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..45c5762 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspace_dir="${1:-}" + +if [ -z "${workspace_dir}" ]; then + echo "post-create.sh requires the workspace path as its first argument" >&2 + exit 1 +fi + +git config --global --add safe.directory "${workspace_dir}" + +cd "${workspace_dir}" +hatch -v env create +micropython "${workspace_dir}/.devcontainer/install_micropython_lib.py" diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 41fb829..64cdd76 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -7,18 +7,18 @@ on: jobs: build: + uses: ./.github/workflows/build.yml + + release: + needs: build runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 - - name: Build - run: | - docker run --rm \ - -v ${{ github.workspace }}/build:/build \ - -v ${{ github.workspace }}/rinkhals-webui:/rinkhals-webui \ - ghcr.io/jbatonnet/rinkhals/build \ - /bin/bash -c "chmod +x /build/build-swu.sh && /build/build-swu.sh /rinkhals-webui" + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: swu-packages - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -29,4 +29,4 @@ jobs: --title="${GITHUB_REPOSITORY#*/} ${tag}" \ --generate-notes \ --draft \ - build/dist/update-*.swu + update-*.swu diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..68d4646 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: + pull_request: + workflow_call: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Install build tools + run: | + corepack enable yarn + pip install hatch mpy-cross + - name: Build + run: hatch run build + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: swu-packages + path: build/dist/update-*.swu diff --git a/.gitignore b/.gitignore index de21fd8..77f882e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ +.venv/ +.ruff_cache/ +.yarn/ +.pnp.* +__pycache__/ +node_modules/ build/dist/ diff --git a/README.md b/README.md index 679a8b7..30bb3d1 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,23 @@ The web UI can be accessed at: `http://:1414` ## Development -To package the application as an `update.swu` file, run the following command from the repository root: +This project uses a [dev container](https://containers.dev/) with build dependencies pre-configured. + +To get started, open this repository in VS Code and select **Reopen in Container** when prompted (or run the **Dev Containers: Reopen in Container** command). This requires [Docker](https://www.docker.com/) and the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). + +### Build + +To build the full `update.swu` packages: ``` -docker run --rm -it -v .\build:/build -v .\rinkhals-webui:/rinkhals-webui ghcr.io/jbatonnet/rinkhals/build /build/build-swu.sh "/rinkhals-webui" +hatch run build ``` + +This runs three steps in sequence: +1. `build-frontend`: builds the web frontend +2. `build-mpy`: compiles Micropython bytecode +3. `build-swu`: creates installation packages + +Individual steps can also be run separately: `hatch run `. + +Output files are written to `build/dist/`. diff --git a/build/build-swu.sh b/build/build-swu.sh old mode 100644 new mode 100755 index 648f364..eb8ecc2 --- a/build/build-swu.sh +++ b/build/build-swu.sh @@ -1,52 +1,90 @@ #!/bin/sh -# From a Windows machine: -# docker run --rm -it -v .\build:/build -v .\files:/files -v .\apps:/apps ghcr.io/jbatonnet/rinkhals/build /build/build-swu.sh "APP_PATH" +# Create SWU update packages for Rinkhals WebUI +# +# Expects build artifacts to already exist: +# - build/dist/mpy/ +# - build/dist/frontend/ +# +# Run `hatch run build` for the full pipeline, or call individual steps: +# - `hatch run build-frontend` +# - `hatch run build-mpy` +# - `hatch run build-swu` set -e -# Prepare update -mkdir -p /tmp/update_swu -rm -rf /tmp/update_swu/* - -cp /build/update.sh /tmp/update_swu/update.sh - -APP_ROOT=$1 -APP=$(basename $APP_ROOT) +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +STAGING_DIR=$(mktemp -d) +DIST_DIR="$SCRIPT_DIR/dist" +MPY_DIR="$DIST_DIR/mpy" + +cleanup() { + rm -rf "$STAGING_DIR" +} +trap cleanup EXIT + +# Prerequisites +for cmd in zip tar; do + if ! command -v "$cmd" > /dev/null 2>&1; then + echo "Error: $cmd is not installed" >&2 + exit 1 + fi +done + +# Verify build artifacts +if [ ! -d "$DIST_DIR/frontend" ]; then + echo "Error: Frontend not built (build/dist/frontend/ missing)" >&2 + echo "Run 'hatch run build-frontend' first" >&2 + exit 1 +fi -if [ ! -f $APP_ROOT/app.sh ]; then - echo "No app found in $APP_ROOT. Exiting SWU build" +if [ ! -d "$MPY_DIR" ]; then + echo "Error: Compiled bytecode not found (build/dist/mpy/ missing)" >&2 + echo "Run 'hatch run build-mpy' first" >&2 exit 1 fi -echo "Preparing update package for $APP..." +# Stage the package +echo "Staging update package..." -mkdir -p /tmp/update_swu/$APP -cp -r $APP_ROOT/* /tmp/update_swu/$APP/ +APP_STAGE="$STAGING_DIR/rinkhals-webui" +mkdir -p "$APP_STAGE/lib" -# Run app build script -if [ -f $APP_ROOT/build.sh ]; then - cd /tmp/update_swu/$APP - chmod +x build.sh - ./build.sh -fi +# Add top-level app files +cp "$PROJECT_ROOT/rinkhals-webui/app.json" "$APP_STAGE/" +cp "$PROJECT_ROOT/rinkhals-webui/app.sh" "$APP_STAGE/" +chmod +x "$APP_STAGE/app.sh" + +# Add compiled Micropython modules +cp -r "$MPY_DIR/rinkhals_webui" "$APP_STAGE/lib/rinkhals_webui" +cp -r "$MPY_DIR/microdot" "$APP_STAGE/lib/microdot" + +# Add frontend +cp -r "$DIST_DIR/frontend" "$APP_STAGE/static" -# Create the setup.tar.gz -echo "Building update package..." +# Add update.sh at root of staging +cp "$SCRIPT_DIR/update.sh" "$STAGING_DIR/update.sh" -mkdir -p /build/dist/update_swu -rm -rf /build/dist/update_swu/* +# Create setup.tar.gz +echo "Creating setup.tar.gz..." +mkdir -p "$DIST_DIR" +rm -rf "$DIST_DIR/update_swu" +mkdir -p "$DIST_DIR/update_swu" -cd /tmp/update_swu -tar -czf /build/dist/update_swu/setup.tar.gz --exclude='setup.tar.gz' . +cd "$STAGING_DIR" +tar -czf "$DIST_DIR/update_swu/setup.tar.gz" . +# Create encrypted SWU packages +echo "Creating SWU packages..." +cd "$DIST_DIR" +rm -f update-k2p-k3.swu update-ks1.swu update-k3m.swu -# Create the update.swu -rm -rf /build/dist/update.swu +zip -P 'U2FsdGVkX19deTfqpXHZnB5GeyQ/dtlbHjkUnwgCi+w=' -r update-k2p-k3.swu update_swu +zip -P 'U2FsdGVkX1+lG6cHmshPLI/LaQr9cZCjA8HZt6Y8qmbB7riY' -r update-ks1.swu update_swu +zip -P '4DKXtEGStWHpPgZm8Xna9qluzAI8VJzpOsEIgd8brTLiXs8fLSu3vRx8o7fMf4h6' -r update-k3m.swu update_swu -cd /build/dist -zip -P U2FsdGVkX19deTfqpXHZnB5GeyQ/dtlbHjkUnwgCi+w= -r update-k2p-k3.swu update_swu -zip -P U2FsdGVkX1+lG6cHmshPLI/LaQr9cZCjA8HZt6Y8qmbB7riY -r update-ks1.swu update_swu -zip -P 4DKXtEGStWHpPgZm8Xna9qluzAI8VJzpOsEIgd8brTLiXs8fLSu3vRx8o7fMf4h6 -r update-k3m.swu update_swu +#rm -rf "$DIST_DIR/update_swu" -echo "Done, your update packages are ready: build/dist/update-*.swu" +echo "Done! SWU packages:" +ls -1 "$DIST_DIR"/update-*.swu diff --git a/build/compile-mpy.sh b/build/compile-mpy.sh new file mode 100755 index 0000000..ce7639f --- /dev/null +++ b/build/compile-mpy.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Compile all .py files in rinkhals-webui/src/ to .mpy bytecode in build/dist/mpy/ +set -e + +SRC=rinkhals-webui/src +OUT=build/dist/mpy + +rm -rf "$OUT" + +find "$SRC" -name '*.py' | while read -r f; do + rel="${f#$SRC/}" + out="$OUT/${rel%.py}.mpy" + mkdir -p "$(dirname "$out")" + mpy-cross "$f" -o "$out" +done + +echo "Compiled $(find "$OUT" -name '*.mpy' | wc -l) files to $OUT/" diff --git a/build/update.sh b/build/update.sh old mode 100644 new mode 100755 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8358edc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + Rinkhals WebUI + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6cd8686 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "rinkhals-webui-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@poppanator/sveltekit-svg": "^7.0.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@zag-js/color-picker": "^1.40.0", + "@zag-js/svelte": "^1.40.0", + "daisyui": "^5.5.19", + "svelte": "^5.48.2", + "tailwindcss": "^4.2.2", + "vite": "^8.0.8" + }, + "dependencies": { + "@material-symbols/svg-200": "^0.44.6", + "@material-symbols/svg-400": "^0.44.6", + "@zag-js/menu": "^1.40.0", + "@zag-js/toast": "^1.40.0" + }, + "packageManager": "yarn@4.14.1" +} diff --git a/frontend/public/favicon-120.png b/frontend/public/favicon-120.png new file mode 100644 index 0000000..770627d Binary files /dev/null and b/frontend/public/favicon-120.png differ diff --git a/frontend/public/favicon-180.png b/frontend/public/favicon-180.png new file mode 100644 index 0000000..883a275 Binary files /dev/null and b/frontend/public/favicon-180.png differ diff --git a/frontend/public/favicon-57.png b/frontend/public/favicon-57.png new file mode 100644 index 0000000..529d34e Binary files /dev/null and b/frontend/public/favicon-57.png differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..f949826 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..2aead9e --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,29 @@ + + +
+ (activeTab = tab)} /> + +
+ {#if activeTab === 'controls'} + + {:else if activeTab === 'apps'} + + {:else if activeTab === 'system'} + + {/if} +
+ + +
+ diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..9d9fc46 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,18 @@ +@import 'tailwindcss'; +@plugin "daisyui" { + themes: light --default, dark --prefersdark; +} + +:root { + font-size: 20px; +} + +body { + min-width: 30rem; +} + +.icon-inline { + width: 1em; + height: 1em; + fill: currentColor; +} diff --git a/frontend/src/lib/components/ColorPicker.svelte b/frontend/src/lib/components/ColorPicker.svelte new file mode 100644 index 0000000..c782bd6 --- /dev/null +++ b/frontend/src/lib/components/ColorPicker.svelte @@ -0,0 +1,137 @@ + + +
+ +
+ +
+
+
+
+ + +
+
+
+
+ + +
+ +
+ + + R + + G + + B + + # + +
+
+ + +
+ {#each PRESETS as preset} + + {/each} +
+
+ + diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..a034c9c --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,80 @@ + + + e.preventDefault()} + onclose={() => (isOpen = false)} +> + {#if isOpen} + + {/if} + diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte new file mode 100644 index 0000000..d5e658e --- /dev/null +++ b/frontend/src/lib/components/Navbar.svelte @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..1a6c2b0 --- /dev/null +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,50 @@ + + +
+ + + +
diff --git a/frontend/src/lib/components/ToastItem.svelte b/frontend/src/lib/components/ToastItem.svelte new file mode 100644 index 0000000..4341f6f --- /dev/null +++ b/frontend/src/lib/components/ToastItem.svelte @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte new file mode 100644 index 0000000..ee283cf --- /dev/null +++ b/frontend/src/lib/components/Toaster.svelte @@ -0,0 +1,43 @@ + + +
+ {#each api.getToasts() as t, index (t.id)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/apps/AppsTab.svelte b/frontend/src/lib/components/apps/AppsTab.svelte new file mode 100644 index 0000000..b878a46 --- /dev/null +++ b/frontend/src/lib/components/apps/AppsTab.svelte @@ -0,0 +1,111 @@ + + +
+ {#if loading && apps === PLACEHOLDER_APPS} +
+ +
+ {:else if apps.length === 0} +
+
+ +

No apps installed

+
+
+ {:else} + {#each apps as app (app.app)} +
+
+
+ +
+
+ {app.name} + {#if app.version} + v{app.version} + {/if} + + {app.status} + +
+ {#if app.description} +

{app.description}

+ {/if} +
+ + + +
+
+
+ {/each} + {/if} +
diff --git a/frontend/src/lib/components/controls/ControlsTab.svelte b/frontend/src/lib/components/controls/ControlsTab.svelte new file mode 100644 index 0000000..35799c6 --- /dev/null +++ b/frontend/src/lib/components/controls/ControlsTab.svelte @@ -0,0 +1,14 @@ + + +
+ refreshTick++} /> + +
diff --git a/frontend/src/lib/components/controls/FilamentHub.svelte b/frontend/src/lib/components/controls/FilamentHub.svelte new file mode 100644 index 0000000..0159e70 --- /dev/null +++ b/frontend/src/lib/components/controls/FilamentHub.svelte @@ -0,0 +1,311 @@ + + +
+
+ +
+

Filament Hub

+ {#if hub} +
+ {#if hub.temperature != null} + + + {hub.temperature}°C + + {/if} + + {hub.status ?? 'unknown'} + +
+ {/if} +
+ + {#if loading && !hub} +

Loading…

+ + {:else if hub} + +
+ {#each hub.slots as slot, i} + {@const SlotIcon = SLOT_ICONS[i] ?? SLOT_ICONS[0]} + + {/each} +
+ + +
+
+

+ + Dry Box +

+ + {#if hub.dryer.is_active} + + + {:else} + +
+ + +
+ + {/if} +
+ {/if} +
+
+ + + diff --git a/frontend/src/lib/components/controls/FilamentSlotDialog.svelte b/frontend/src/lib/components/controls/FilamentSlotDialog.svelte new file mode 100644 index 0000000..5420ca7 --- /dev/null +++ b/frontend/src/lib/components/controls/FilamentSlotDialog.svelte @@ -0,0 +1,112 @@ + + + + {#if isOpen} + + {/if} + diff --git a/frontend/src/lib/components/controls/Settings.svelte b/frontend/src/lib/components/controls/Settings.svelte new file mode 100644 index 0000000..fe9b23a --- /dev/null +++ b/frontend/src/lib/components/controls/Settings.svelte @@ -0,0 +1,164 @@ + + +
+
+

Settings

+ +
+ + + + + + + +
+ +
+
diff --git a/frontend/src/lib/components/system/SystemTab.svelte b/frontend/src/lib/components/system/SystemTab.svelte new file mode 100644 index 0000000..0fa87d8 --- /dev/null +++ b/frontend/src/lib/components/system/SystemTab.svelte @@ -0,0 +1,225 @@ + + +
+ + +
+
+

Rinkhals

+ +
+ +
+ + {#if logsVisible} +
{logsContent || '(empty)'}
+ {/if} +
+
+ + +
+
+

Printer

+ +
+ + + +
+
+
+ + +
+
+

Firmware Update

+ +
+ +
+ + {#if updateInfo} + {#if updateInfo.status === 'timed_out'} + + {:else if updateInfo.status === 'error'} + + {:else if updateInfo.status === 'up_to_date'} + + {:else} + +
+

+ + Update available{#if updateInfo.version} — v{updateInfo.version}{/if} +

+ {#if updateInfo.release_date} +

Released: {updateInfo.release_date}

+ {/if} + {#if updateInfo.size_mb} +

Size: {updateInfo.size_mb} MB

+ {/if} + {#if updateInfo.description} +

{updateInfo.description}

+ {/if} +

+ Install via USB drive — see documentation. +

+
+ {/if} + {/if} +
+
+
+ + + diff --git a/frontend/src/lib/toast.js b/frontend/src/lib/toast.js new file mode 100644 index 0000000..5ebde25 --- /dev/null +++ b/frontend/src/lib/toast.js @@ -0,0 +1,8 @@ +import * as toast from '@zag-js/toast'; + +export const toaster = toast.createStore({ + placement: 'bottom-end', + max: 8, + gap: 12, + offsets: '1rem', +}); diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..6f0061c --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,12 @@ +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.getElementById('app'), + props: { + printerName: 'Kobra' + } +}); + +export default app; diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..8a09b83 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,36 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { readFileSync } from 'fs'; +import tailwindcss from '@tailwindcss/vite'; +import svg from '@poppanator/sveltekit-svg'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appJson = JSON.parse(readFileSync(resolve(__dirname, '../rinkhals-webui/app.json'), 'utf-8')); + +// Export a Vite config that drops the build artifacts directly in the +// Microdot static directory so the Python dev server can serve them. +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(appJson.version), + }, + plugins: [tailwindcss(), svelte(), svg()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + build: { + outDir: '../build/dist/frontend', + emptyOutDir: true + }, + server: { + host: "0.0.0.0", + port: 5173, + strictPort: true, + watch: { + usePolling: true, + }, + } +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 0000000..372568a --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,1510 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 9 + cacheKey: 10c0 + +"@emnapi/core@npm:1.9.2": + version: 1.9.2 + resolution: "@emnapi/core@npm:1.9.2" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.1" + tslib: "npm:^2.4.0" + checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9 + languageName: node + linkType: hard + +"@emnapi/core@npm:^1.8.1": + version: 1.10.0 + resolution: "@emnapi/core@npm:1.10.0" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.1" + tslib: "npm:^2.4.0" + checksum: 10c0/f51d08227857b60632de7714d708124f0e100a1462dde6df8221760939aa3204a73193830371830fac0716f3ccd2129f2cac1b17cd7d7958bc4da9018a296edb + languageName: node + linkType: hard + +"@emnapi/runtime@npm:1.9.2": + version: 1.9.2 + resolution: "@emnapi/runtime@npm:1.9.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.8.1": + version: 1.10.0 + resolution: "@emnapi/runtime@npm:1.10.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/953f14991d1aefb92ee6f8eb27dea725e484791a53a0cb5f47d9e0087b9a2c929ff2e92adf95af15d6ad456db6300c6b761ebf72b50a875b874a83520b3ba093 + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.2.1, @emnapi/wasi-threads@npm:^1.1.0": + version: 1.2.1 + resolution: "@emnapi/wasi-threads@npm:1.2.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331 + languageName: node + linkType: hard + +"@floating-ui/core@npm:^1.7.5": + version: 1.7.5 + resolution: "@floating-ui/core@npm:1.7.5" + dependencies: + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/f9c52205e198b231d63a387b09c659aab08c46a1899e0b0bbe147b8b4f048b546f15ba17cb5d2a471da9534f1883d979425e13e5c4ceee67be63e4b0abd4db5d + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.7.6": + version: 1.7.6 + resolution: "@floating-ui/dom@npm:1.7.6" + dependencies: + "@floating-ui/core": "npm:^1.7.5" + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/5c098e0d7b58c9bc769f276cca1766994c2c9c70c92d091a61bba8b3e9be53c011e0a79a8457fc2fb2f3d91697a26eb52e0a4962ef936dc963b45f58613c212f + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.11": + version: 0.2.11 + resolution: "@floating-ui/utils@npm:0.2.11" + checksum: 10c0/f4bcea1559bdbb721ecc8e8ead423ac58d6a5b6e70b602cf0810ba6ad4ed1c77211b207faa88b278a9042f0c743133de08a203ed6741c1b6443423332884d5b3 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/remapping@npm:^2.3.4, @jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.24": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@material-symbols/svg-200@npm:^0.44.6": + version: 0.44.6 + resolution: "@material-symbols/svg-200@npm:0.44.6" + checksum: 10c0/c26435f009643526ffb5837fe99c49b11bee14b9d6b7f94064e9826bf7ed98823e749d1043c5f7bd6e859697db2f7c20e04fa27f12562ae9763cc162884e2e1d + languageName: node + linkType: hard + +"@material-symbols/svg-400@npm:^0.44.6": + version: 0.44.6 + resolution: "@material-symbols/svg-400@npm:0.44.6" + checksum: 10c0/6ef415d2d5757b427878291c8b6f8719f591b3151cde8cf6f4b6a44a3e3ddd681eee92101604747c2a7c802efed17cf1d6248bbad788202f6de15bf45798502e + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.1, @napi-rs/wasm-runtime@npm:^1.1.3": + version: 1.1.4 + resolution: "@napi-rs/wasm-runtime@npm:1.1.4" + dependencies: + "@tybys/wasm-util": "npm:^0.10.1" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/2e88e1955258949ccf2d18c79975821ad38071b465ef126a5e14110977b97868867b016c1ad046e963cccc42c0bd9db6c8ff5fd1ebb61b87bb3487f339041658 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.124.0": + version: 0.124.0 + resolution: "@oxc-project/types@npm:0.124.0" + checksum: 10c0/9564ee3ce41f4b87802ffd0d62a7602d27f4503fbd39c1bedab98d54fde06e2ac254a8f85d8f679af1281a26e8fc7aa053fadbb3e09e786b38178eb38a8e2fb3 + languageName: node + linkType: hard + +"@poppanator/sveltekit-svg@npm:^7.0.0": + version: 7.0.0 + resolution: "@poppanator/sveltekit-svg@npm:7.0.0" + dependencies: + "@rollup/pluginutils": "npm:5.3.0" + svgo: "npm:^4.0.1" + peerDependencies: + svelte: ">=5.x" + vite: ">= 6 || >= 7 || >= 8" + checksum: 10c0/78995b79b58b6351ba718d353afd741f3c80a5529421fe28b4ce183ef88dc0f0c0b9314bcdfbb7e481cd6e67ede60036d9a57432652c7e04b2749a08a0e5cca3 + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.15" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.15" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.15" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.15" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.15" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.15" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.15" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.15" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.15" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.15" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.15" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.15" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.15" + dependencies: + "@emnapi/core": "npm:1.9.2" + "@emnapi/runtime": "npm:1.9.2" + "@napi-rs/wasm-runtime": "npm:^1.1.3" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.15" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.15" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.15" + checksum: 10c0/15eef6a65ee6b2d07405c16999c2333c40d8aeea60bbc35e04957992fe6477c7b278d3f02679688bb928ad2ef3fbd3a6149c116d7dc9928ebf8d1434a0591674 + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:5.3.0": + version: 5.3.0 + resolution: "@rollup/pluginutils@npm:5.3.0" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^4.0.2" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/001834bf62d7cf5bac424d2617c113f7f7d3b2bf3c1778cbcccb72cdc957b68989f8e7747c782c2b911f1dde8257f56f8ac1e779e29e74e638e3f1e2cac2bcd0 + languageName: node + linkType: hard + +"@sveltejs/acorn-typescript@npm:^1.0.5": + version: 1.0.8 + resolution: "@sveltejs/acorn-typescript@npm:1.0.8" + peerDependencies: + acorn: ^8.9.0 + checksum: 10c0/3de68af48db0b9cbc82872b218cd9134f494ba7716872e8c11bfdbb156b11dba2205541a627eed943733e4a4e8bbb261fe898bf7659105e1a4c641033fe3d4fe + languageName: node + linkType: hard + +"@sveltejs/vite-plugin-svelte@npm:^7.0.0": + version: 7.0.0 + resolution: "@sveltejs/vite-plugin-svelte@npm:7.0.0" + dependencies: + deepmerge: "npm:^4.3.1" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.0" + vitefu: "npm:^1.1.2" + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + checksum: 10c0/201f925bd0d94f251fec679e9d9f46778e4b1546df208b815e07ac8e929777de35e2c6c3d1191b776b734cb4859b613b2d58512d34d69cdef8519634adb0da9a + languageName: node + linkType: hard + +"@tailwindcss/node@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/node@npm:4.2.2" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + enhanced-resolve: "npm:^5.19.0" + jiti: "npm:^2.6.1" + lightningcss: "npm:1.32.0" + magic-string: "npm:^0.30.21" + source-map-js: "npm:^1.2.1" + tailwindcss: "npm:4.2.2" + checksum: 10c0/4c0019355cd85a08f93ba3e179de37b83cc233b8ded4bd7714e633f89dd108928742e50966593257c2c1ab8db8914ea187dae007b5c692c869ceace11aeccede + languageName: node + linkType: hard + +"@tailwindcss/oxide-android-arm64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-android-arm64@npm:4.2.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-arm64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.2.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-x64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-darwin-x64@npm:4.2.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-freebsd-x64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.2.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.2.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-gnu@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.2.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-musl@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.2.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-gnu@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.2.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-musl@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.2.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-wasm32-wasi@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.2.2" + dependencies: + "@emnapi/core": "npm:^1.8.1" + "@emnapi/runtime": "npm:^1.8.1" + "@emnapi/wasi-threads": "npm:^1.1.0" + "@napi-rs/wasm-runtime": "npm:^1.1.1" + "@tybys/wasm-util": "npm:^0.10.1" + tslib: "npm:^2.8.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-arm64-msvc@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.2.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-x64-msvc@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.2.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide@npm:4.2.2" + dependencies: + "@tailwindcss/oxide-android-arm64": "npm:4.2.2" + "@tailwindcss/oxide-darwin-arm64": "npm:4.2.2" + "@tailwindcss/oxide-darwin-x64": "npm:4.2.2" + "@tailwindcss/oxide-freebsd-x64": "npm:4.2.2" + "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.2.2" + "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.2.2" + "@tailwindcss/oxide-linux-arm64-musl": "npm:4.2.2" + "@tailwindcss/oxide-linux-x64-gnu": "npm:4.2.2" + "@tailwindcss/oxide-linux-x64-musl": "npm:4.2.2" + "@tailwindcss/oxide-wasm32-wasi": "npm:4.2.2" + "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.2.2" + "@tailwindcss/oxide-win32-x64-msvc": "npm:4.2.2" + dependenciesMeta: + "@tailwindcss/oxide-android-arm64": + optional: true + "@tailwindcss/oxide-darwin-arm64": + optional: true + "@tailwindcss/oxide-darwin-x64": + optional: true + "@tailwindcss/oxide-freebsd-x64": + optional: true + "@tailwindcss/oxide-linux-arm-gnueabihf": + optional: true + "@tailwindcss/oxide-linux-arm64-gnu": + optional: true + "@tailwindcss/oxide-linux-arm64-musl": + optional: true + "@tailwindcss/oxide-linux-x64-gnu": + optional: true + "@tailwindcss/oxide-linux-x64-musl": + optional: true + "@tailwindcss/oxide-wasm32-wasi": + optional: true + "@tailwindcss/oxide-win32-arm64-msvc": + optional: true + "@tailwindcss/oxide-win32-x64-msvc": + optional: true + checksum: 10c0/22f78d73ffcec2d0d91f9fbfc29fed23c260e3e53f510f0b2598e322bf56a92ceb7e6f5a1c88ad1e3c7cfee9dd8d39285c411de5ec3225cdae2cbfdb737862e5 + languageName: node + linkType: hard + +"@tailwindcss/vite@npm:^4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/vite@npm:4.2.2" + dependencies: + "@tailwindcss/node": "npm:4.2.2" + "@tailwindcss/oxide": "npm:4.2.2" + tailwindcss: "npm:4.2.2" + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + checksum: 10c0/f6ec4b0d6a8e79208873fb357a8ed9b6fd8eb3000d153ec2590c61dba5bfbe79c0951a215d187958d2b8a3c5b45c25ebcefac7a6dea882bb27b4b2898c54266f + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.1": + version: 0.10.1 + resolution: "@tybys/wasm-util@npm:0.10.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/b255094f293794c6d2289300c5fbcafbb5532a3aed3a5ffd2f8dc1828e639b88d75f6a376dd8f94347a44813fd7a7149d8463477a9a49525c8b2dcaa38c2d1e8 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5, @types/estree@npm:^1.0.6": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + +"@zag-js/anatomy@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/anatomy@npm:1.40.0" + checksum: 10c0/fdedc6b751757495cfe03f9f1831f463d0481ba0411ccc868dadffa4813cec87d236b755b56e4f204e3b9a062962e737289b5a68e3cde4673397e2bbf4a02513 + languageName: node + linkType: hard + +"@zag-js/color-picker@npm:^1.40.0": + version: 1.40.0 + resolution: "@zag-js/color-picker@npm:1.40.0" + dependencies: + "@zag-js/anatomy": "npm:1.40.0" + "@zag-js/color-utils": "npm:1.40.0" + "@zag-js/core": "npm:1.40.0" + "@zag-js/dismissable": "npm:1.40.0" + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/popper": "npm:1.40.0" + "@zag-js/types": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/f6db66fe540ae74d47fb079f1a189ec3a3fe432a25f8a5b54d84c69c802b23a8cac9e5b6a785d649c8b6a18837d733eae53c2515c654a9ceeee0a695562dd074 + languageName: node + linkType: hard + +"@zag-js/color-utils@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/color-utils@npm:1.40.0" + dependencies: + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/c51b9003f96a3cbf41708047544120492b93ef9909fac3a42121af5cd439d43ede6864836a218383d7e125c36e3ec5875e32b6f9358b70b4f1b668f2c4490f3a + languageName: node + linkType: hard + +"@zag-js/core@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/core@npm:1.40.0" + dependencies: + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/82b276256f9c95b52f9fc45cf42ba8fcac88c3619849f2e62ce05ce18c372a33474314f1516e8f71f240e2a037c3c79b40a556a414ae23c2ed5ca9702bb9bd5b + languageName: node + linkType: hard + +"@zag-js/dismissable@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/dismissable@npm:1.40.0" + dependencies: + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/interact-outside": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/58932f22f768c796e5b3cc0e5f41c2b44d6c86c1b0f391f05e31f319ffd56a761b936b59b8310bfd82bb0431ee5ed2c2defe91ab27f6f6dda44d5ff92b4f6b5c + languageName: node + linkType: hard + +"@zag-js/dom-query@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/dom-query@npm:1.40.0" + dependencies: + "@zag-js/types": "npm:1.40.0" + checksum: 10c0/5e7d14374565afdc4a25c318b7b939b84d803a01016bc293256e19ccf5654bd078774d13834eed85e1b43fa26ba27fdba9567b1ec62f83ec8c9c90337a51c468 + languageName: node + linkType: hard + +"@zag-js/focus-visible@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/focus-visible@npm:1.40.0" + dependencies: + "@zag-js/dom-query": "npm:1.40.0" + checksum: 10c0/5bd5a4bb51a89673a0855a6013f0808e88c658baa587cc05a4623dd873da3dc66941b657830fbd156ec5a4e3bf3b240ae4a09484fa912f2d90ee704fa590d878 + languageName: node + linkType: hard + +"@zag-js/interact-outside@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/interact-outside@npm:1.40.0" + dependencies: + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/c289ab53c414768d5a69729c01ec4d0a195cfafd88249d68fc380338bf4eb970db27b0b3bbccaa029d66753152a4964ce568f1e65645511ab067deb2589cdc67 + languageName: node + linkType: hard + +"@zag-js/menu@npm:^1.40.0": + version: 1.40.0 + resolution: "@zag-js/menu@npm:1.40.0" + dependencies: + "@zag-js/anatomy": "npm:1.40.0" + "@zag-js/core": "npm:1.40.0" + "@zag-js/dismissable": "npm:1.40.0" + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/focus-visible": "npm:1.40.0" + "@zag-js/popper": "npm:1.40.0" + "@zag-js/rect-utils": "npm:1.40.0" + "@zag-js/types": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/424abe7d780f682c6c65b3de4b0e715dad4a3d80c72457e972cc35e0d4512ac280a9afbd6837e61aee9502b8e5c2ddedbe0f05c84e13af309c7a5058612420ff + languageName: node + linkType: hard + +"@zag-js/popper@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/popper@npm:1.40.0" + dependencies: + "@floating-ui/dom": "npm:^1.7.6" + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/0c040afb32353a6ebe1fb0f0615a73fa396ae28d5ddfbba4236787a0ba9a6893f3c7d064c870f28e137396b0a2f183db0043278a6b03136b35c037783036ea6d + languageName: node + linkType: hard + +"@zag-js/rect-utils@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/rect-utils@npm:1.40.0" + checksum: 10c0/0f2e2297755d53c8dc594737bc2d4879145b15456146a69e8b47219eb26053c5f2f98ceab67ffe8fd3c3faa63c3bdf84ec31450e077065d75f02a1235f6b4c40 + languageName: node + linkType: hard + +"@zag-js/svelte@npm:^1.40.0": + version: 1.40.0 + resolution: "@zag-js/svelte@npm:1.40.0" + dependencies: + "@zag-js/core": "npm:1.40.0" + "@zag-js/types": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + peerDependencies: + svelte: ">=5" + checksum: 10c0/f10ce2f1df4b6e5bf200874df4fa3ef8da70b07da681a9cb3e3eec89669d56442e7da17b7fb70c1927c74dc3531b0e4c771a929994b9c44974c28d65aa9138bc + languageName: node + linkType: hard + +"@zag-js/toast@npm:^1.40.0": + version: 1.40.0 + resolution: "@zag-js/toast@npm:1.40.0" + dependencies: + "@zag-js/anatomy": "npm:1.40.0" + "@zag-js/core": "npm:1.40.0" + "@zag-js/dismissable": "npm:1.40.0" + "@zag-js/dom-query": "npm:1.40.0" + "@zag-js/types": "npm:1.40.0" + "@zag-js/utils": "npm:1.40.0" + checksum: 10c0/32a43331500b77116a5eb625ee91ed180f08a498a49bd6b5a8247db1acbac16b039b64ee8abda09d7797cd3c2fe76e2560c7d0770d29d33b6e41efb46b98ee10 + languageName: node + linkType: hard + +"@zag-js/types@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/types@npm:1.40.0" + dependencies: + csstype: "npm:3.2.3" + checksum: 10c0/2cfa8834c2b60f9b3625233b6e6d0be4a9154a4635f7a0c1d63f16517160ad41feea7954215021827cbb3ffacca2efb3f92caf2e7f388a9f40b11847ebd4d63a + languageName: node + linkType: hard + +"@zag-js/utils@npm:1.40.0": + version: 1.40.0 + resolution: "@zag-js/utils@npm:1.40.0" + checksum: 10c0/203b75618a594afa9a11a5726947d7d1f098f690525a1532324ca58b058bc5e8a01a61e578086fbfecb49e1a9b83fb0ec8098790f944ade30ead3ef6582241e5 + languageName: node + linkType: hard + +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 + languageName: node + linkType: hard + +"acorn@npm:^8.12.1": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" + bin: + acorn: bin/acorn + checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec + languageName: node + linkType: hard + +"aria-query@npm:^5.3.1": + version: 5.3.2 + resolution: "aria-query@npm:5.3.2" + checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e + languageName: node + linkType: hard + +"axobject-query@npm:^4.1.0": + version: 4.1.0 + resolution: "axobject-query@npm:4.1.0" + checksum: 10c0/c470e4f95008f232eadd755b018cb55f16c03ccf39c027b941cd8820ac6b68707ce5d7368a46756db4256fbc91bb4ead368f84f7fb034b2b7932f082f6dc0775 + languageName: node + linkType: hard + +"boolbase@npm:^1.0.0": + version: 1.0.0 + resolution: "boolbase@npm:1.0.0" + checksum: 10c0/e4b53deb4f2b85c52be0e21a273f2045c7b6a6ea002b0e139c744cb6f95e9ec044439a52883b0d74dedd1ff3da55ed140cfdddfed7fb0cccbed373de5dce1bcf + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + +"commander@npm:^11.1.0": + version: 11.1.0 + resolution: "commander@npm:11.1.0" + checksum: 10c0/13cc6ac875e48780250f723fb81c1c1178d35c5decb1abb1b628b3177af08a8554e76b2c0f29de72d69eef7c864d12613272a71fabef8047922bc622ab75a179 + languageName: node + linkType: hard + +"css-select@npm:^5.1.0": + version: 5.2.2 + resolution: "css-select@npm:5.2.2" + dependencies: + boolbase: "npm:^1.0.0" + css-what: "npm:^6.1.0" + domhandler: "npm:^5.0.2" + domutils: "npm:^3.0.1" + nth-check: "npm:^2.0.1" + checksum: 10c0/d79fffa97106007f2802589f3ed17b8c903f1c961c0fc28aa8a051eee0cbad394d8446223862efd4c1b40445a6034f626bb639cf2035b0bfc468544177593c99 + languageName: node + linkType: hard + +"css-tree@npm:^3.0.1": + version: 3.2.1 + resolution: "css-tree@npm:3.2.1" + dependencies: + mdn-data: "npm:2.27.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/1f65e9ccaa56112a4706d6f003dd43d777f0dbcf848e66fd320f823192533581f8dd58daa906cb80622658332d50284d6be13b87a6ab4556cbbfe9ef535bbf7e + languageName: node + linkType: hard + +"css-tree@npm:~2.2.0": + version: 2.2.1 + resolution: "css-tree@npm:2.2.1" + dependencies: + mdn-data: "npm:2.0.28" + source-map-js: "npm:^1.0.1" + checksum: 10c0/47e87b0f02f8ac22f57eceb65c58011dd142d2158128882a0bf963cf2eabb81a4ebbc2e3790c8289be7919fa8b83750c7b69272bd66772c708143b772ba3c186 + languageName: node + linkType: hard + +"css-what@npm:^6.1.0": + version: 6.2.2 + resolution: "css-what@npm:6.2.2" + checksum: 10c0/91e24c26fb977b4ccef30d7007d2668c1c10ac0154cc3f42f7304410e9594fb772aea4f30c832d2993b132ca8d99338050866476210316345ec2e7d47b248a56 + languageName: node + linkType: hard + +"csso@npm:^5.0.5": + version: 5.0.5 + resolution: "csso@npm:5.0.5" + dependencies: + css-tree: "npm:~2.2.0" + checksum: 10c0/ab4beb1e97dd7e207c10e9925405b45f15a6cd1b4880a8686ad573aa6d476aed28b4121a666cffd26c37a26179f7b54741f7c257543003bfb244d06a62ad569b + languageName: node + linkType: hard + +"csstype@npm:3.2.3": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"daisyui@npm:^5.5.19": + version: 5.5.19 + resolution: "daisyui@npm:5.5.19" + checksum: 10c0/10e33c5e602b491b6e82e369fd08f1737359a327361cd464e87e2bf9e14e0601bf362db03e6846ae398f76d3f2cf524e071d3fec85b18d6637faddd628d2a10a + languageName: node + linkType: hard + +"deepmerge@npm:^4.3.1": + version: 4.3.1 + resolution: "deepmerge@npm:4.3.1" + checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + +"devalue@npm:^5.6.2": + version: 5.6.2 + resolution: "devalue@npm:5.6.2" + checksum: 10c0/654f257ec525a2d3f35c941bfbb361148bc65ced060710969fbaa1c45abf1c9d7c4fcb77310bf8d2fb73c34cf60bad10710e7bf5b15643bbc082518ea04cb00b + languageName: node + linkType: hard + +"dom-serializer@npm:^2.0.0": + version: 2.0.0 + resolution: "dom-serializer@npm:2.0.0" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.2" + entities: "npm:^4.2.0" + checksum: 10c0/d5ae2b7110ca3746b3643d3ef60ef823f5f078667baf530cec096433f1627ec4b6fa8c072f09d079d7cda915fd2c7bc1b7b935681e9b09e591e1e15f4040b8e2 + languageName: node + linkType: hard + +"domelementtype@npm:^2.3.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 + languageName: node + linkType: hard + +"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": + version: 5.0.3 + resolution: "domhandler@npm:5.0.3" + dependencies: + domelementtype: "npm:^2.3.0" + checksum: 10c0/bba1e5932b3e196ad6862286d76adc89a0dbf0c773e5ced1eb01f9af930c50093a084eff14b8de5ea60b895c56a04d5de8bbc4930c5543d029091916770b2d2a + languageName: node + linkType: hard + +"domutils@npm:^3.0.1": + version: 3.2.2 + resolution: "domutils@npm:3.2.2" + dependencies: + dom-serializer: "npm:^2.0.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + checksum: 10c0/47938f473b987ea71cd59e59626eb8666d3aa8feba5266e45527f3b636c7883cca7e582d901531961f742c519d7514636b7973353b648762b2e3bedbf235fada + languageName: node + linkType: hard + +"enhanced-resolve@npm:^5.19.0": + version: 5.20.1 + resolution: "enhanced-resolve@npm:5.20.1" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.3.0" + checksum: 10c0/c6503ee1b2d725843e047e774445ecb12b779aa52db25d11ebe18d4b3adc148d3d993d2038b3d0c38ad836c9c4b3930fbc55df42f72b44785e2f94e5530eda69 + languageName: node + linkType: hard + +"entities@npm:^4.2.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"esm-env@npm:^1.2.1": + version: 1.2.2 + resolution: "esm-env@npm:1.2.2" + checksum: 10c0/3d25c973f2fd69c25ffff29c964399cea573fe10795ecc1d26f6f957ce0483d3254e1cceddb34bf3296a0d7b0f1d53a28992f064ba509dfe6366751e752c4166 + languageName: node + linkType: hard + +"esrap@npm:^2.2.1": + version: 2.2.2 + resolution: "esrap@npm:2.2.2" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.4.15" + checksum: 10c0/859675c5cc529a7260b1a9c1fc308eacd9090c1b615adfe2bfc706dada1381daaf5c293fae25da9b28f0ec134cac9e70dc7fb6559fc1d973fe808c7e19cf6e9b + languageName: node + linkType: hard + +"estree-walker@npm:^2.0.2": + version: 2.0.2 + resolution: "estree-walker@npm:2.0.2" + checksum: 10c0/53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"is-reference@npm:^3.0.3": + version: 3.0.3 + resolution: "is-reference@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.6" + checksum: 10c0/35edd284cfb4cd9e9f08973f20e276ec517eaca31f5f049598e97dbb2d05544973dde212dac30fddee5b420930bff365e2e67dcd1293d0866c6720377382e3e5 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"jiti@npm:^2.6.1": + version: 2.6.1 + resolution: "jiti@npm:2.6.1" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:1.32.0, lightningcss@npm:^1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + +"locate-character@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-character@npm:3.0.0" + checksum: 10c0/9da917622395002eb1336fca8cbef1c19904e3dc0b3b8078abe8ff390106d947a86feccecd0346f0e0e19fa017623fb4ccb65263d72a76dfa36e20cc18766b6c + languageName: node + linkType: hard + +"magic-string@npm:^0.30.11, magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"mdn-data@npm:2.0.28": + version: 2.0.28 + resolution: "mdn-data@npm:2.0.28" + checksum: 10c0/20000932bc4cd1cde9cba4e23f08cc4f816398af4c15ec81040ed25421d6bf07b5cf6b17095972577fb498988f40f4cb589e3169b9357bb436a12d8e07e5ea7b + languageName: node + linkType: hard + +"mdn-data@npm:2.27.1": + version: 2.27.1 + resolution: "mdn-data@npm:2.27.1" + checksum: 10c0/eb8abf5d22e4d1e090346f5e81b67d23cef14c83940e445da5c44541ad874dc8fb9f6ca236e8258c3a489d9fb5884188a4d7d58773adb9089ac2c0b966796393 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"nanoid@npm:^3.3.11": + version: 3.3.11 + resolution: "nanoid@npm:3.3.11" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 12.3.0 + resolution: "node-gyp@npm:12.3.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^6.25.0" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/9d9032b405cbe42f72a105259d9eb679376470c102df4a2dbaa51e07d59bf741dcffb85897087ea9d8318b9cabb824a8978af51508ae142f0239ae1e6a3c2329 + languageName: node + linkType: hard + +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: "npm:^4.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd + languageName: node + linkType: hard + +"nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" + dependencies: + boolbase: "npm:^1.0.0" + checksum: 10c0/5fee7ff309727763689cfad844d979aedd2204a817fbaaf0e1603794a7c20db28548d7b024692f953557df6ce4a0ee4ae46cd8ebd9b36cfb300b9226b567c479 + languageName: node + linkType: hard + +"obug@npm:^2.1.0": + version: 2.1.1 + resolution: "obug@npm:2.1.1" + checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + +"postcss@npm:^8.5.8": + version: 8.5.9 + resolution: "postcss@npm:8.5.9" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/7cb2b32202ea1ead03f15cfbb2756a64a0f98942378e99b3dfce33678fe5eaf93e31d675a46e3a0dfb417d7b49b82d8999d0dd42a33c3b128e71ade0f978719a + languageName: node + linkType: hard + +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 + languageName: node + linkType: hard + +"rinkhals-webui-frontend@workspace:.": + version: 0.0.0-use.local + resolution: "rinkhals-webui-frontend@workspace:." + dependencies: + "@material-symbols/svg-200": "npm:^0.44.6" + "@material-symbols/svg-400": "npm:^0.44.6" + "@poppanator/sveltekit-svg": "npm:^7.0.0" + "@sveltejs/vite-plugin-svelte": "npm:^7.0.0" + "@tailwindcss/vite": "npm:^4.2.2" + "@zag-js/color-picker": "npm:^1.40.0" + "@zag-js/menu": "npm:^1.40.0" + "@zag-js/svelte": "npm:^1.40.0" + "@zag-js/toast": "npm:^1.40.0" + daisyui: "npm:^5.5.19" + svelte: "npm:^5.48.2" + tailwindcss: "npm:^4.2.2" + vite: "npm:^8.0.8" + languageName: unknown + linkType: soft + +"rolldown@npm:1.0.0-rc.15": + version: 1.0.0-rc.15 + resolution: "rolldown@npm:1.0.0-rc.15" + dependencies: + "@oxc-project/types": "npm:=0.124.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.15" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.15" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.15" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.15" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.15" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.15" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.15" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.15" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.15" + "@rolldown/pluginutils": "npm:1.0.0-rc.15" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10c0/95df21125dafd2a0ce6ae9a89d926540e47900684023126c84632e18123371020da8f6b3235a188c45af0e4f9a5b963235de33bd9658ee5db9f3ff5862200eed + languageName: node + linkType: hard + +"sax@npm:^1.5.0": + version: 1.6.0 + resolution: "sax@npm:1.6.0" + checksum: 10c0/e5593f4a91eb25761a688c4d96902e4e95a0dd6017bc65146b6f21236e3d715cf893333b76bc758923c9574c2fb5a7a76c3a81e96ea15432f2624f906c027c1e + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.8.0 + resolution: "semver@npm:7.8.0" + bin: + semver: bin/semver.js + checksum: 10c0/8f096ca9b80ffd47b308d03f9ce8c873e27e2983f36023c559cdc92c51e8433fc23ebbfe57ec9623fc155636a6961ee989501099841ae4bb1babc8d2b3f048cd + languageName: node + linkType: hard + +"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"svelte@npm:^5.48.2": + version: 5.48.2 + resolution: "svelte@npm:5.48.2" + dependencies: + "@jridgewell/remapping": "npm:^2.3.4" + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@sveltejs/acorn-typescript": "npm:^1.0.5" + "@types/estree": "npm:^1.0.5" + acorn: "npm:^8.12.1" + aria-query: "npm:^5.3.1" + axobject-query: "npm:^4.1.0" + clsx: "npm:^2.1.1" + devalue: "npm:^5.6.2" + esm-env: "npm:^1.2.1" + esrap: "npm:^2.2.1" + is-reference: "npm:^3.0.3" + locate-character: "npm:^3.0.0" + magic-string: "npm:^0.30.11" + zimmerframe: "npm:^1.1.2" + checksum: 10c0/8aeacb9b2047a9796d7ce941354f18e3600bde9ef2fae214d6c842b1df53109952a6c676cf2afbff5a237fc913bb47845ee09144e1d06166b6be3c69c79dbe64 + languageName: node + linkType: hard + +"svgo@npm:^4.0.1": + version: 4.0.1 + resolution: "svgo@npm:4.0.1" + dependencies: + commander: "npm:^11.1.0" + css-select: "npm:^5.1.0" + css-tree: "npm:^3.0.1" + css-what: "npm:^6.1.0" + csso: "npm:^5.0.5" + picocolors: "npm:^1.1.1" + sax: "npm:^1.5.0" + bin: + svgo: ./bin/svgo.js + checksum: 10c0/f61f9957b42e0c97593b49a01f3b93cb239b2e818efccb056fcc866d622704d9021c4ef8bd0287264c3e4f33da60e4af14d841b4a624b87ff8888e3b896d13d5 + languageName: node + linkType: hard + +"tailwindcss@npm:4.2.2, tailwindcss@npm:^4.2.2": + version: 4.2.2 + resolution: "tailwindcss@npm:4.2.2" + checksum: 10c0/6eae8a125c35d504ba6c518d26ec64fba694ff4a9ab9b9cd9883050128e0b7afdf491388c472d9bed2624664c1c7d4a133d19b653151a6b52e6ce6953168a857 + languageName: node + linkType: hard + +"tapable@npm:^2.3.0": + version: 2.3.2 + resolution: "tapable@npm:2.3.2" + checksum: 10c0/45ec8bd8963907f35bba875f9b3e9a5afa5ba11a9a4e4a2d7b2313d983cb2741386fd7dd3e54b13055b2be942971aac369d197e02263ec9216c59c0a8069ed7f + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.15 + resolution: "tar@npm:7.5.15" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/8f039edb1d12fdd7df6c6f9877d125afe9f3da3f5f9317df326fdd090d48793d6998cede1506a1471f3e3a250db270a89dace28005eb5e99c5a9132d704ac956 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b + languageName: node + linkType: hard + +"tslib@npm:^2.4.0, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"undici@npm:^6.25.0": + version: 6.25.0 + resolution: "undici@npm:6.25.0" + checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985 + languageName: node + linkType: hard + +"vite@npm:^8.0.8": + version: 8.0.8 + resolution: "vite@npm:8.0.8" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.8" + rolldown: "npm:1.0.0-rc.15" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.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 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/63474b399612ccf087d0aa025d7eb5c0d675012b6257b7f64332ff39579d4af4d5d7f0ac330906fc99b101abbf592c756adf143bb5748a02aec08f7d3639054d + languageName: node + linkType: hard + +"vitefu@npm:^1.1.2": + version: 1.1.3 + resolution: "vitefu@npm:1.1.3" + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/6cfcd52a17339f63f71ef08787a68850a17a9d9dd7a688e29e41067f963eb2cd5192bb65d38df87fa3a733a21e0229aaa6382201655c1599699e5d6669e3e094 + languageName: node + linkType: hard + +"which@npm:^6.0.0": + version: 6.0.1 + resolution: "which@npm:6.0.1" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"zimmerframe@npm:^1.1.2": + version: 1.1.4 + resolution: "zimmerframe@npm:1.1.4" + checksum: 10c0/9470cbf22cefae975ab413c7158a119d082b354ddcf0da48a842f2f42246fa15943cd9b92c047de39db38015e3b866e32f383bc217e8e4f4192945c7d425536b + languageName: node + linkType: hard diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4485f51 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "rinkhals-webui" +version = "2.0.0" +description = "Web interface for Rinkhals and Kobra-specific features." +license = { text = "MIT" } +authors = [{ name = "Bas van Doren" }] +requires-python = ">=3.11" +keywords = ["micropython", "webui", "rinkhals"] + +[dependency-groups] +dev = [ + "micropython-unix-stubs>=1.26.0.post1", + "mpy-cross>=1.24.1", + "ruff>=0.14.14", + "pyright>=1.1.408", + "pytest>=9.0.2", +] + +[tool.hatch.build.targets.wheel] +packages = [ + "rinkhals-webui/src/rinkhals_webui", + "rinkhals-webui/src/microdot" +] + +[tool.hatch.envs.default] +path = ".venv" +dependency-groups = [ + "dev", +] + +[tool.hatch.envs.default.env-vars] +MICROPYPATH = "rinkhals-webui/src:.frozen:/home/vscode/.micropython/lib:/usr/lib/micropython" +STATIC_DIR = "build/dist/frontend" + +[tool.hatch.envs.default.scripts] +lint = "ruff check rinkhals-webui/src" +format = "ruff format rinkhals-webui/src" +test = "pytest" +run = "micropython -m rinkhals_webui" +repl = "micropython" +build-frontend = "cd frontend && yarn install --frozen-lockfile && yarn build" +build-mpy = "sh build/compile-mpy.sh" +build-swu = "sh build/build-swu.sh" +build = ["build-frontend", "build-mpy", "build-swu"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..0dba5f9 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,21 @@ +{ + "venvPath": ".", + "venv": ".venv", + "include": [ + "rinkhals-webui/src", + "rinkhals-webui/scripts" + ], + "ignore": [ + "rinkhals-webui/src/microdot" + ], + "executionEnvironments": [ + { + "root": ".", + "extraPaths": [ + "rinkhals-webui/src" + ] + } + ], + // Micropython type stubs have no source + "reportMissingModuleSource": "none" +} diff --git a/rinkhals-webui/app.sh b/rinkhals-webui/app.sh old mode 100644 new mode 100755 index 4302d34..8c607c7 --- a/rinkhals-webui/app.sh +++ b/rinkhals-webui/app.sh @@ -1,29 +1,70 @@ source /useremain/rinkhals/.current/tools.sh APP_ROOT=$(dirname $(realpath $0)) +PID_FILE=$APP_ROOT/app.pid +LOG_FILE=$RINKHALS_LOGS/rinkhals-webui.log -status() { - PIDS=$(get_by_name OliveTin) +# Check if PID file points to a live rinkhals_webui process +is_running() { + if [ ! -f "$PID_FILE" ]; then + return 1 + fi + + PID=$(cat "$PID_FILE") + if [ -z "$PID" ] || [ ! -e "/proc/$PID/cmdline" ]; then + rm -f "$PID_FILE" + return 1 + fi + + CMDLINE=$(get_command_line $PID) + case "$CMDLINE" in + *rinkhals_webui*) + return 0 + ;; + esac + + log "Stale PID file (PID $PID is not rinkhals_webui), removing" + rm -f "$PID_FILE" + return 1 +} - if [ "$PIDS" == "" ]; then - report_status $APP_STATUS_STOPPED +status() { + if is_running; then + PID=$(cat "$PID_FILE") + report_status $APP_STATUS_STARTED "$PID" "$LOG_FILE" else - report_status $APP_STATUS_STARTED "$PIDS" + report_status $APP_STATUS_STOPPED "" "$LOG_FILE" fi } start() { - cd $APP_ROOT + if is_running; then + log "Rinkhals WebUI already running (PID $(cat "$PID_FILE"))" + return + fi - chmod +x ./scripts/* ./OliveTin - ./OliveTin & + export STATIC_DIR=$APP_ROOT/static - log "Started Rinkhals WebUI" + cd $APP_ROOT/lib + micropython -m rinkhals_webui >> "$LOG_FILE" 2>&1 & + echo $! > "$PID_FILE" + + log "Started Rinkhals WebUI (PID $!)" } stop() { - kill_by_name OliveTin + if ! is_running; then + log "Rinkhals WebUI is not running" + return + fi + + PID=$(cat "$PID_FILE") + kill_by_id $PID + rm -f "$PID_FILE" log "Stopped Rinkhals WebUI" } +version() { + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$APP_ROOT/app.json" +} case "$1" in status) @@ -35,8 +76,11 @@ case "$1" in stop) stop ;; + version) + version + ;; *) - echo "Usage: $0 {status|start|stop}" >&2 + echo "Usage: $0 {status|start|stop|version}" >&2 exit 1 ;; esac diff --git a/rinkhals-webui/build.sh b/rinkhals-webui/build.sh deleted file mode 100644 index 3f40d7b..0000000 --- a/rinkhals-webui/build.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh - -set -e - -if [[ "$(pwd)" != "/tmp/update_swu/rinkhals-webui" ]]; then - echo "Script was not called during SWU build. Exiting" - exit 1 -fi - -cd /tmp - -wget -O olivetin.tar.gz https://github.com/OliveTin/OliveTin/releases/download/2025.2.21/OliveTin-linux-arm7.tar.gz -tar --strip-components=1 --skip-old-files -xvzf olivetin.tar.gz \ - -C /tmp/update_swu/rinkhals-webui \ - "OliveTin-linux-arm7/OliveTin" "OliveTin-linux-arm7/webui/" - -cd /tmp/update_swu/rinkhals-webui - -# mv -f ./custom-webui/*.png ./webui/ -rm ./build.sh diff --git a/build/LICENSE b/rinkhals-webui/src/microdot/LICENSE similarity index 94% rename from build/LICENSE rename to rinkhals-webui/src/microdot/LICENSE index dcf67c5..ba45cea 100644 --- a/build/LICENSE +++ b/rinkhals-webui/src/microdot/LICENSE @@ -1,6 +1,6 @@ -The MIT License (MIT) +MIT License -Copyright (c) 2025 Julien Batonnet +Copyright (c) 2019 Miguel Grinberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/rinkhals-webui/src/microdot/README.md b/rinkhals-webui/src/microdot/README.md new file mode 100644 index 0000000..90f3470 --- /dev/null +++ b/rinkhals-webui/src/microdot/README.md @@ -0,0 +1,7 @@ +# Microdot manual copy + +These sources originate from https://github.com/miguelgrinberg/microdot at commit `0ba1b928797d674e10841c722ff50b310adbc5c8` (January 15, 2024). + +The files are included verbatim to provide Microdot and its core extension modules on MicroPython targets where installing from PyPI is impractical. + +To update, re-clone the upstream repository at the desired commit and replace this directory, ensuring you also copy the LICENSE file. diff --git a/rinkhals-webui/src/microdot/VENDORED.md b/rinkhals-webui/src/microdot/VENDORED.md new file mode 100644 index 0000000..2f658a4 --- /dev/null +++ b/rinkhals-webui/src/microdot/VENDORED.md @@ -0,0 +1,7 @@ +# Microdot Vendored Copy + +These sources originate from https://github.com/miguelgrinberg/microdot at commit `0ba1b928797d674e10841c722ff50b310adbc5c8` (January 15, 2024). + +The files are included verbatim to provide Microdot and its core extension modules on MicroPython targets where installing from PyPI is impractical. + +To update, re-clone the upstream repository at the desired commit and replace this directory, ensuring you also copy the LICENSE file. diff --git a/rinkhals-webui/src/microdot/__init__.py b/rinkhals-webui/src/microdot/__init__.py new file mode 100644 index 0000000..5d1e1b0 --- /dev/null +++ b/rinkhals-webui/src/microdot/__init__.py @@ -0,0 +1,4 @@ +from microdot.microdot import Microdot, Request, Response, abort, redirect, \ + send_file, URLPattern, AsyncBytesIO, iscoroutine # noqa: F401 + +__version__ = '2.5.2.dev0' diff --git a/rinkhals-webui/src/microdot/asgi.py b/rinkhals-webui/src/microdot/asgi.py new file mode 100644 index 0000000..d533de1 --- /dev/null +++ b/rinkhals-webui/src/microdot/asgi.py @@ -0,0 +1,274 @@ +import asyncio +import os +import signal +from microdot import * # noqa: F401, F403 +from microdot.microdot import Microdot as BaseMicrodot, Request, Response, \ + NoCaseDict, abort +from microdot.websocket import WebSocket as BaseWebSocket, websocket_wrapper + + +class _BodyStream: # pragma: no cover + def __init__(self, receive): + self.receive = receive + self.data = b'' + self.more = True + + async def read_more(self): + if self.more: + packet = await self.receive() + self.data += packet.get('body', b'') + self.more = packet.get('more_body', False) + + async def read(self, n=-1): + while self.more and len(self.data) < n: + await self.read_more() + if len(self.data) < n: + data = self.data + self.data = b'' + return data + + data = self.data[:n] + self.data = self.data[n:] + return data + + async def readline(self): + return await self.readuntil() + + async def readexactly(self, n): + return await self.read(n) + + async def readuntil(self, separator=b'\n'): + if self.more and separator not in self.data: + await self.read_more() + data, self.data = self.data.split(separator, 1) + return data + + +class Microdot(BaseMicrodot): + """A subclass of the core :class:`Microdot ` class that + implements the ASGI protocol. + + :param startup: An optional function to handle the `lifespan.startup` ASGI + signal. + :param shutdown: An optional function to handle the `lifespan.shutdown` + ASGI signal. + + This class must be used as the application instance when running under an + ASGI web server. + """ + def __init__(self, lifespan_startup=None, lifespan_shutdown=None): + super().__init__() + self.lifespan_startup = lifespan_startup + self.lifespan_shutdown = lifespan_shutdown + self.embedded_server = False + + async def handle_lifespan(self, scope, receive, send): + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + try: + if self.lifespan_startup: + await self.lifespan_startup(scope) + except Exception as e: + await send({'type': 'lifespan.startup.failed', + 'message': repr(e)}) + else: + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': # pragma: no branch + try: + if self.lifespan_shutdown: + await self.lifespan_shutdown(scope) + except Exception as e: + await send({'type': 'lifespan.shutdown.failed', + 'message': repr(e)}) + else: + await send({'type': 'lifespan.shutdown.complete'}) + break + + async def asgi_app(self, scope, receive, send): + """An ASGI application.""" + if scope['type'] == 'lifespan': + return await self.handle_lifespan(scope, receive, send) + if scope['type'] not in ['http', 'websocket']: # pragma: no cover + return + path = scope['path'] + if 'query_string' in scope and scope['query_string']: + path += '?' + scope['query_string'].decode() + headers = NoCaseDict() + content_length = 0 + for key, value in scope.get('headers', []): + key = key.decode().title() + headers[key] = value.decode() + if key == 'Content-Length': + content_length = int(value) + + if content_length and content_length <= Request.max_body_length: + body = b'' + more = True + while more: + packet = await receive() + body += packet.get('body', b'') + more = packet.get('more_body', False) + stream = None + else: + body = b'' + stream = _BodyStream(receive) + + req = Request( + self, + (scope['client'][0], scope['client'][1]), + scope.get('method', 'GET'), + path, + 'HTTP/' + scope['http_version'], + headers, + body=body, + stream=stream, + sock=(receive, send), + scheme=scope.get('scheme')) + req.asgi_scope = scope + + res = await self.dispatch_request(req) + res.complete() + + header_list = [] + for name, value in res.headers.items(): + if not isinstance(value, list): + header_list.append((name.lower().encode(), value.encode())) + else: + for v in value: + header_list.append((name.lower().encode(), v.encode())) + + if scope['type'] != 'http': # pragma: no cover + return + + await send({'type': 'http.response.start', + 'status': res.status_code, + 'headers': header_list}) + + cancelled = False + + async def cancel_monitor(): + nonlocal cancelled + + while True: + event = await receive() + if event is None or \ + event['type'] == 'http.disconnect': # pragma: no cover + cancelled = True + break + + monitor_task = asyncio.ensure_future(cancel_monitor()) + + body_iter = res.body_iter().__aiter__() + try: + while not cancelled: # pragma: no branch + res_body = await body_iter.__anext__() + if isinstance(res_body, str): + res_body = res_body.encode() + await send({'type': 'http.response.body', + 'body': res_body, + 'more_body': True}) + except StopAsyncIteration: + pass + await send({'type': 'http.response.body', + 'body': b'', + 'more_body': False}) + if hasattr(body_iter, 'aclose'): # pragma: no branch + await body_iter.aclose() + cancelled = True + await monitor_task + + async def __call__(self, scope, receive, send): + return await self.asgi_app(scope, receive, send) + + def shutdown(self): + if self.embedded_server: # pragma: no cover + super().shutdown() + else: + pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid() + os.kill(pid, signal.SIGTERM) + + def run(self, host='0.0.0.0', port=5000, debug=False, + **options): # pragma: no cover + """Normally you would not start the server by invoking this method. + Instead, start your chosen ASGI web server and pass the ``Microdot`` + instance as the ASGI application. + """ + self.embedded_server = True + super().run(host=host, port=port, debug=debug, **options) + + +class WebSocket(BaseWebSocket): # pragma: no cover + async def handshake(self): + connect = await self.request.sock[0]() + if connect['type'] != 'websocket.connect': + abort(400) + await self.request.sock[1]({'type': 'websocket.accept'}) + + async def receive(self): + message = await self.request.sock[0]() + if message['type'] == 'websocket.disconnect': + raise OSError(32, 'Websocket connection closed') + elif message['type'] != 'websocket.receive': + raise OSError(32, 'Websocket message type not supported') + return message.get('bytes', message.get('text')) + + async def send(self, data): + if isinstance(data, str): + await self.request.sock[1]( + {'type': 'websocket.send', 'text': data}) + else: + await self.request.sock[1]( + {'type': 'websocket.send', 'bytes': data}) + + async def close(self): + if not self.closed: + self.closed = True + try: + await self.request.sock[1]({'type': 'websocket.close'}) + except: # noqa E722 + pass + + +async def websocket_upgrade(request): # pragma: no cover + """Upgrade a request handler to a websocket connection. + + This function can be called directly inside a route function to process a + WebSocket upgrade handshake, for example after the user's credentials are + verified. The function returns the websocket object:: + + @app.route('/echo') + async def echo(request): + if not (await authenticate_user(request)): + abort(401) + ws = await websocket_upgrade(request) + while True: + message = await ws.receive() + await ws.send(message) + """ + ws = WebSocket(request) if not request.app.embedded_server else \ + BaseWebSocket(request) + await ws.handshake() + + @request.after_request + async def after_request(request, response): + return Response.already_handled + + return ws + + +def with_websocket(f): # pragma: no cover + """Decorator to make a route a WebSocket endpoint. + + This decorator is used to define a route that accepts websocket + connections. The route then receives a websocket object as a second + argument that it can use to send and receive messages:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + """ + return websocket_wrapper(f, websocket_upgrade) diff --git a/rinkhals-webui/src/microdot/auth.py b/rinkhals-webui/src/microdot/auth.py new file mode 100644 index 0000000..71574ee --- /dev/null +++ b/rinkhals-webui/src/microdot/auth.py @@ -0,0 +1,162 @@ +from microdot import abort +from microdot.microdot import invoke_handler + + +class BaseAuth: + def __init__(self): + self.auth_callback = None + self.error_callback = None + + def __call__(self, f): + """Decorator to protect a route with authentication. + + An instance of this class must be used as a decorator on the routes + that need to be protected. Example:: + + auth = BasicAuth() # or TokenAuth() + + @app.route('/protected') + @auth + def protected(request): + # ... + + Routes that are decorated in this way will only be invoked if the + authentication callback returned a valid user object, otherwise the + error callback will be executed. + """ + async def wrapper(request, *args, **kwargs): + auth = self._get_auth(request) + if not auth: + return await invoke_handler(self.error_callback, request) + request.g.current_user = await invoke_handler( + self.auth_callback, request, *auth) + if not request.g.current_user: + return await invoke_handler(self.error_callback, request) + return await invoke_handler(f, request, *args, **kwargs) + + return wrapper + + def optional(self, f): + """Decorator to protect a route with optional authentication. + + This decorator makes authentication for the decorated route optional, + meaning that the route is allowed to run with or with + authentication given in the request. + """ + async def wrapper(request, *args, **kwargs): + auth = self._get_auth(request) + if not auth: + request.g.current_user = None + else: + request.g.current_user = await invoke_handler( + self.auth_callback, request, *auth) + return await invoke_handler(f, request, *args, **kwargs) + + return wrapper + + +class BasicAuth(BaseAuth): + """Basic Authentication. + + :param realm: The realm that is displayed when the user is prompted to + authenticate in the browser. + :param charset: The charset that is used to encode the realm. + :param scheme: The authentication scheme. Defaults to 'Basic'. + :param error_status: The error status code to return when authentication + fails. Defaults to 401. + """ + def __init__(self, realm='Please login', charset='UTF-8', scheme='Basic', + error_status=401): + super().__init__() + self.realm = realm + self.charset = charset + self.scheme = scheme + self.error_status = error_status + self.error_callback = self.authentication_error + + def _get_auth(self, request): + auth = request.headers.get('Authorization') + if auth and auth.startswith('Basic '): + import binascii + try: + username, password = binascii.a2b_base64( + auth[6:]).decode().split(':', 1) + except Exception: # pragma: no cover + return None + return username, password + + async def authentication_error(self, request): + return '', self.error_status, { + 'WWW-Authenticate': '{} realm="{}", charset="{}"'.format( + self.scheme, self.realm, self.charset)} + + def authenticate(self, f): + """Decorator to configure the authentication callback. + + This decorator must be used with a function that accepts the request + object, a username and a password and returns a user object if the + credentials are valid, or ``None`` if they are not. Example:: + + @auth.authenticate + async def check_credentials(request, username, password): + user = get_user(username) + if user and user.check_password(password): + return get_user(username) + """ + self.auth_callback = f + + +class TokenAuth(BaseAuth): + """Token based authentication. + + :param header: The name of the header that will contain the token. Defaults + to 'Authorization'. + :param scheme: The authentication scheme. Defaults to 'Bearer'. + :param error_status: The error status code to return when authentication + fails. Defaults to 401. + """ + def __init__(self, header='Authorization', scheme='Bearer', + error_status=401): + super().__init__() + self.header = header + self.scheme = scheme.lower() + self.error_status = error_status + self.error_callback = self.authentication_error + + def _get_auth(self, request): + auth = request.headers.get(self.header) + if auth: + if self.header == 'Authorization': + try: + scheme, token = auth.split(' ', 1) + except Exception: + return None + if scheme.lower() == self.scheme: + return (token.strip(),) + else: + return (auth,) + + def authenticate(self, f): + """Decorator to configure the authentication callback. + + This decorator must be used with a function that accepts the request + object, a username and a password and returns a user object if the + credentials are valid, or ``None`` if they are not. Example:: + + @auth.authenticate + async def check_credentials(request, token): + return get_user(token) + """ + self.auth_callback = f + + def errorhandler(self, f): + """Decorator to configure the error callback. + + Microdot calls the error callback to allow the application to generate + a custom error response. The default error response is to call + ``abort(401)``. + """ + self.error_callback = f + + async def authentication_error(self, request): + abort(self.error_status) diff --git a/rinkhals-webui/src/microdot/cors.py b/rinkhals-webui/src/microdot/cors.py new file mode 100644 index 0000000..3c24935 --- /dev/null +++ b/rinkhals-webui/src/microdot/cors.py @@ -0,0 +1,111 @@ +class CORS: + """Add CORS headers to HTTP responses. + + :param app: The application to add CORS headers to. + :param allowed_origins: A list of origins that are allowed to make + cross-site requests. If set to '*', all origins are + allowed. + :param allow_credentials: If set to True, the + ``Access-Control-Allow-Credentials`` header will + be set to ``true`` to indicate to the browser + that it can expose cookies and authentication + headers. + :param allowed_methods: A list of methods that are allowed to be used when + making cross-site requests. If not set, all methods + are allowed. + :param expose_headers: A list of headers that the browser is allowed to + exposed. + :param allowed_headers: A list of headers that are allowed to be used when + making cross-site requests. If not set, all headers + are allowed. + :param max_age: The maximum amount of time in seconds that the browser + should cache the results of a preflight request. + :param handle_cors: If set to False, CORS headers will not be added to + responses. This can be useful if you want to add CORS + headers manually. + """ + def __init__(self, app=None, allowed_origins=None, allow_credentials=False, + allowed_methods=None, expose_headers=None, + allowed_headers=None, max_age=None, handle_cors=True): + self.allowed_origins = allowed_origins + self.allow_credentials = allow_credentials + self.allowed_methods = allowed_methods + self.expose_headers = expose_headers + self.allowed_headers = None if allowed_headers is None \ + else [h.lower() for h in allowed_headers] + self.max_age = max_age + if app is not None: + self.initialize(app, handle_cors=handle_cors) + + def initialize(self, app, handle_cors=True): + """Initialize the CORS object for the given application. + + :param app: The application to add CORS headers to. + :param handle_cors: If set to False, CORS headers will not be added to + responses. This can be useful if you want to add + CORS headers manually. + """ + self.default_options_handler = app.options_handler + if handle_cors: + app.options_handler = self.options_handler + app.after_request(self.after_request) + app.after_error_request(self.after_request) + + def options_handler(self, request): + headers = self.default_options_handler(request) + headers.update(self.get_cors_headers(request)) + return headers + + def get_cors_headers(self, request): + """Return a dictionary of CORS headers to add to a given request. + + :param request: The request to add CORS headers to. + """ + cors_headers = {} + origin = request.headers.get('Origin') + if self.allowed_origins == '*': + cors_headers['Access-Control-Allow-Origin'] = origin or '*' + if origin: + cors_headers['Vary'] = 'Origin' + elif origin in (self.allowed_origins or []): + cors_headers['Access-Control-Allow-Origin'] = origin + cors_headers['Vary'] = 'Origin' + if self.allow_credentials and \ + 'Access-Control-Allow-Origin' in cors_headers: + cors_headers['Access-Control-Allow-Credentials'] = 'true' + if self.expose_headers: + cors_headers['Access-Control-Expose-Headers'] = \ + ', '.join(self.expose_headers) + + if request.method == 'OPTIONS': + # handle preflight request + if self.max_age: + cors_headers['Access-Control-Max-Age'] = str(self.max_age) + + method = request.headers.get('Access-Control-Request-Method') + if method: + method = method.upper() + if self.allowed_methods is None or \ + method in self.allowed_methods: + cors_headers['Access-Control-Allow-Methods'] = method + + headers = request.headers.get('Access-Control-Request-Headers') + if headers: + if self.allowed_headers is None: + cors_headers['Access-Control-Allow-Headers'] = headers + else: + headers = [h.strip() for h in headers.split(',')] + headers = [h for h in headers + if h.lower() in self.allowed_headers] + cors_headers['Access-Control-Allow-Headers'] = \ + ', '.join(headers) + + return cors_headers + + def after_request(self, request, response): + saved_vary = response.headers.get('Vary') + if request: # pragma: no branch + response.headers.update(self.get_cors_headers(request)) + if saved_vary and saved_vary != response.headers.get('Vary'): + response.headers['Vary'] = ( + saved_vary + ', ' + response.headers['Vary']) diff --git a/rinkhals-webui/src/microdot/csrf.py b/rinkhals-webui/src/microdot/csrf.py new file mode 100644 index 0000000..dcee5da --- /dev/null +++ b/rinkhals-webui/src/microdot/csrf.py @@ -0,0 +1,120 @@ +from microdot import abort + + +class CSRF: + """CSRF protection for Microdot routes. + + :param app: The application instance. + :param cors: The ``CORS`` instance that defines the origins that are + trusted by the application. This is used to validate requests + from older browsers that do not send the ``Sec-Fetch-Site`` + header. + :param protect_all: If ``True``, all state changing routes are protected by + default, with the exception of routes that are + decorated with the :meth:`exempt ` decorator. + If ``False``, only routes decorated with the + :meth:`protect ` decorator are protected. The + default is ``True``. + :param allow_subdomains: If ``True``, requests from subdomains of the + application domain are trusted. The default is + ``False``. + + CSRF protection is implemented by checking the ``Sec-Fetch-Site`` sent by + browsers. When the ``cors`` argument is provided, requests from older + browsers that do not support the ``Sec-Fetch-Site`` header are validated + by checking the ``Origin`` header. + """ + SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] + + def __init__(self, app=None, cors=None, protect_all=True, + allow_subdomains=False): + self.cors = None + self.protect_all = protect_all + self.allow_subdomains = allow_subdomains + self.exempt_routes = [] + self.protected_routes = [] + if app is not None: + self.initialize(app, cors) + + def initialize(self, app, cors=None): + """Initialize the CSRF class. + + :param app: The application instance. + :param cors: The ``CORS`` instance that defines the origins that are + trusted by the application. This is used to validate + requests from older browsers that do not send the + ``Sec-Fetch-Site`` header. + """ + self.cors = cors + + @app.before_request + async def csrf_before_request(request): + if ( + self.protect_all + and request.method not in self.SAFE_METHODS + and request.route not in self.exempt_routes + ) or request.route in self.protected_routes: + allow = False + sfs = request.headers.get('Sec-Fetch-Site') + origin = request.headers.get('Origin') + if sfs: + # if the Sec-Fetch-Site header was given, ensure it is not + # cross-site + if sfs in ['same-origin', 'none']: + allow = True + elif sfs == 'same-site' and self.allow_subdomains: + allow = True + if not allow and origin and self.cors and \ + self.cors.allowed_origins != '*': + # if we have a list of allowed origins, then we can + # validate the origin + if not self.allow_subdomains: + allow = origin in self.cors.allowed_origins + else: + origin_scheme, origin_host = origin.split('://', 1) + for allowed_origin in self.cors.allowed_origins: + allowed_scheme, allowed_host = \ + allowed_origin.split('://', 1) + if origin == allowed_origin or ( + origin_host.endswith('.' + allowed_host) + and origin_scheme == allowed_scheme + ): + allow = True + break + if not allow and not sfs and not origin: + allow = True # no headers to check + + if not allow: + abort(403, 'Forbidden') + + def exempt(self, f): + """Decorator to exempt a route from CSRF protection. + + This decorator must be added immediately after the route decorator to + disable CSRF protection on the route. Example:: + + @app.post('/submit') + @csrf.exempt + # add additional decorators here + def submit(request): + # ... + """ + self.exempt_routes.append(f) + return f + + def protect(self, f): + """Decorator to protect a route against CSRF attacks. + + This is useful when it is necessary to protect a request that uses one + of the safe methods that are not supposed to make state changes. The + decorator must be added immediately after the route decorator to + disable CSRF protection on the route. Example:: + + @app.get('/data') + @csrf.force + # add additional decorators here + def get_data(request): + # ... + """ + self.protected_routes.append(f) + return f diff --git a/rinkhals-webui/src/microdot/helpers.py b/rinkhals-webui/src/microdot/helpers.py new file mode 100644 index 0000000..664e58c --- /dev/null +++ b/rinkhals-webui/src/microdot/helpers.py @@ -0,0 +1,8 @@ +try: + from functools import wraps +except ImportError: # pragma: no cover + # MicroPython does not currently implement functools.wraps + def wraps(wrapped): + def _(wrapper): + return wrapper + return _ diff --git a/rinkhals-webui/src/microdot/login.py b/rinkhals-webui/src/microdot/login.py new file mode 100644 index 0000000..53cdb5f --- /dev/null +++ b/rinkhals-webui/src/microdot/login.py @@ -0,0 +1,172 @@ +from time import time +from microdot import redirect +from microdot.microdot import urlencode, invoke_handler + + +class Login: + """User login support for Microdot. + + :param login_url: the URL to redirect to when a login is required. The + default is '/login'. + """ + def __init__(self, login_url='/login'): + self.login_url = login_url + self.user_loader_callback = None + + def user_loader(self, f): + """Decorator to configure the user callback. + + The decorated function receives the user ID as an argument and must + return the corresponding user object, or ``None`` if the user ID is + invalid. + """ + self.user_loader_callback = f + + def _get_session(self, request): + return request.app._session.get(request) + + def _update_remember_cookie(self, request, days, user_id=None): + remember_payload = request.app._session.encode({ + 'user_id': user_id, + 'days': days, + 'exp': time() + days * 24 * 60 * 60 + }) + + @request.after_request + async def _set_remember_cookie(request, response): + response.set_cookie('_remember', remember_payload, + max_age=days * 24 * 60 * 60) + return response + + def _get_user_id_from_session(self, request): + session = self._get_session(request) + if session and '_user_id' in session: + return session['_user_id'] + if '_remember' in request.cookies: + remember_payload = request.app._session.decode( + request.cookies['_remember']) + user_id = remember_payload.get('user_id') + if user_id: # pragma: no branch + self._update_remember_cookie( + request, remember_payload.get('_days', 30), user_id) + session['_user_id'] = user_id + session['_fresh'] = False + session.save() + return user_id + + async def _redirect_to_login(self, request): + return '', 302, {'Location': self.login_url + '?next=' + urlencode( + request.url)} + + async def login_user(self, request, user, remember=False, + redirect_url='/'): + """Log a user in. + + :param request: the request object + :param user: the user object + :param remember: if the user's logged in state should be remembered + with a cookie after the session ends. Set to the + number of days the remember cookie should last, or to + ``True`` to use a default duration of 30 days. + :param redirect_url: the URL to redirect to after login + + This call marks the user as logged in by storing their user ID in the + user session. The application must call this method to log a user in + after their credentials have been validated. + + The method returns a redirect response, either to the URL the user + originally intended to visit, or if there is no original URL to the URL + specified by the `redirect_url`. + """ + session = self._get_session(request) + session['_user_id'] = user.id + session['_fresh'] = True + session.save() + request.g.current_user = user + + if remember: + days = 30 if remember is True else int(remember) + self._update_remember_cookie(request, days, session['_user_id']) + + next_url = request.args.get('next', redirect_url) + if not next_url.startswith('/'): + next_url = redirect_url + return redirect(next_url) + + async def logout_user(self, request): + """Log a user out. + + :param request: the request object + + This call removes information about the user's log in from the user + session. If a remember cookie exists, it is removed as well. + """ + session = self._get_session(request) + session.pop('_user_id', None) + session.pop('_fresh', None) + session.save() + request.g.current_user = None + if '_remember' in request.cookies: + self._update_remember_cookie(request, 0) + + async def get_current_user(self, request): + """Return the currently logged in user.""" + if not hasattr(request.g, 'current_user'): + user_id = self._get_user_id_from_session(request) + if user_id: + request.g.current_user = await invoke_handler( + self.user_loader_callback, user_id) + else: + request.g.current_user = None + return request.g.current_user + + def __call__(self, f): + """Decorator to protect a route with authentication. + + If the user is not logged in, Microdot will redirect to the login page + first. The decorated route will only run after successful login by the + user. If the user is already logged in, the route will run immediately. + Example:: + + login = Login() + + @app.route('/secret') + @login + async def secret(request): + # only accessible to authenticated users + + """ + async def wrapper(request, *args, **kwargs): + user = await self.get_current_user(request) + if not user: + return await self._redirect_to_login(request) + return await invoke_handler(f, request, *args, **kwargs) + + return wrapper + + def fresh(self, f): + """Decorator to protect a route with "fresh" authentication. + + This decorator prevents the route from running when the login session + is not fresh. A fresh session is a session that has been created from + direct user interaction with the login page, while a non-fresh session + occurs when a login is restored from a "remember me" cookie. Example:: + + login = Login() + + @app.route('/secret') + @auth.fresh + async def secret(request): + # only accessible to authenticated users + # users logged in via remember me cookie will need to + # re-authenticate + """ + base_wrapper = self.__call__(f) + + async def wrapper(request, *args, **kwargs): + session = self._get_session(request) + if session.get('_fresh'): + return await base_wrapper(request, *args, **kwargs) + return await self._redirect_to_login(request) + + return wrapper diff --git a/rinkhals-webui/src/microdot/microdot.py b/rinkhals-webui/src/microdot/microdot.py new file mode 100644 index 0000000..ccb170e --- /dev/null +++ b/rinkhals-webui/src/microdot/microdot.py @@ -0,0 +1,1551 @@ +""" +microdot +-------- + +The ``microdot`` module defines a few classes that help implement HTTP-based +servers for MicroPython and standard Python. +""" +import asyncio +import io +import re +import time + +try: + import orjson as json +except ImportError: + import json + +try: + from inspect import iscoroutinefunction, iscoroutine + from functools import partial + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in a thread pool executor. + """ + if iscoroutinefunction(handler): + ret = await handler(*args, **kwargs) + else: + ret = await asyncio.get_running_loop().run_in_executor( + None, partial(handler, *args, **kwargs)) + return ret +except ImportError: # pragma: no cover + def iscoroutine(coro): + return hasattr(coro, 'send') and hasattr(coro, 'throw') + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in the asyncio thread, which can + potentially cause blocking and performance issues. + """ + ret = handler(*args, **kwargs) + if iscoroutine(ret): + ret = await ret + return ret + +try: + from sys import print_exception +except ImportError: # pragma: no cover + import traceback + + def print_exception(exc): + traceback.print_exc() + +MUTED_SOCKET_ERRORS = [ + 32, # Broken pipe + 54, # Connection reset by peer + 104, # Connection reset by peer + 128, # Operation on closed socket +] + + +def urldecode(s): + if isinstance(s, str): + s = s.encode() + s = s.replace(b'+', b' ') + parts = s.split(b'%') + if len(parts) == 1: + return s.decode() + result = [parts[0]] + for item in parts[1:]: + if item == b'': + result.append(b'%') + else: + code = item[:2] + result.append(bytes([int(code, 16)])) + result.append(item[2:]) + return b''.join(result).decode() + + +def urlencode(s): + return s.replace('+', '%2B').replace(' ', '+').replace( + '%', '%25').replace('?', '%3F').replace('#', '%23').replace( + '&', '%26').replace('=', '%3D') + + +class NoCaseDict(dict): + """A subclass of dictionary that holds case-insensitive keys. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = NoCaseDict() + >>> d['Content-Type'] = 'text/html' + >>> print(d['Content-Type']) + text/html + >>> print(d['content-type']) + text/html + >>> print(d['CONTENT-TYPE']) + text/html + >>> del d['cOnTeNt-TyPe'] + >>> print(d) + {} + """ + def __init__(self, initial_dict=None): + super().__init__(initial_dict or {}) + self.keymap = {k.lower(): k for k in self.keys() if k.lower() != k} + + def __setitem__(self, key, value): + kl = key.lower() + key = self.keymap.get(kl, key) + if kl != key: + self.keymap[kl] = key + super().__setitem__(key, value) + + def __getitem__(self, key): + kl = key.lower() + return super().__getitem__(self.keymap.get(kl, kl)) + + def __delitem__(self, key): + kl = key.lower() + super().__delitem__(self.keymap.get(kl, kl)) + + def __contains__(self, key): + kl = key.lower() + return self.keymap.get(kl, kl) in self.keys() + + def get(self, key, default=None): + kl = key.lower() + return super().get(self.keymap.get(kl, kl), default) + + def update(self, other_dict): + for key, value in other_dict.items(): + self[key] = value + + +def mro(cls): # pragma: no cover + """Return the method resolution order of a class. + + This is a helper function that returns the method resolution order of a + class. It is used by Microdot to find the best error handler to invoke for + the raised exception. + + In CPython, this function returns the ``__mro__`` attribute of the class. + In MicroPython, this function implements a recursive depth-first scanning + of the class hierarchy. + """ + if hasattr(cls, 'mro'): + return cls.__mro__ + + def _mro(cls): + m = [cls] + for base in cls.__bases__: + m += _mro(base) + return m + + mro_list = _mro(cls) + + # If a class appears multiple times (due to multiple inheritance) remove + # all but the last occurence. This matches the method resolution order + # of MicroPython, but not CPython. + mro_pruned = [] + for i in range(len(mro_list)): + base = mro_list.pop(0) + if base not in mro_list: + mro_pruned.append(base) + return mro_pruned + + +class MultiDict(dict): + """A subclass of dictionary that can hold multiple values for the same + key. It is used to hold key/value pairs decoded from query strings and + form submissions. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = MultiDict() + >>> d['sort'] = 'name' + >>> d['sort'] = 'email' + >>> print(d['sort']) + 'name' + >>> print(d.getlist('sort')) + ['name', 'email'] + """ + def __init__(self, initial_dict=None): + super().__init__() + if initial_dict: + for key, value in initial_dict.items(): + self[key] = value + + def __setitem__(self, key, value): + if key not in self: + super().__setitem__(key, []) + super().__getitem__(key).append(value) + + def __getitem__(self, key): + return super().__getitem__(key)[0] + + def get(self, key, default=None, type=None): + """Return the value for a given key. + + :param key: The key to retrieve. + :param default: A default value to use if the key does not exist. + :param type: A type conversion callable to apply to the value. + + If the multidict contains more than one value for the requested key, + this method returns the first value only. + + Example:: + + >>> d = MultiDict() + >>> d['age'] = '42' + >>> d.get('age') + '42' + >>> d.get('age', type=int) + 42 + >>> d.get('name', default='noname') + 'noname' + """ + if key not in self: + return default + value = self[key] + if type is not None: + value = type(value) + return value + + def getlist(self, key, type=None): + """Return all the values for a given key. + + :param key: The key to retrieve. + :param type: A type conversion callable to apply to the values. + + If the requested key does not exist in the dictionary, this method + returns an empty list. + + Example:: + + >>> d = MultiDict() + >>> d.getlist('items') + [] + >>> d['items'] = '3' + >>> d.getlist('items') + ['3'] + >>> d['items'] = '56' + >>> d.getlist('items') + ['3', '56'] + >>> d.getlist('items', type=int) + [3, 56] + """ + if key not in self: + return [] + values = super().__getitem__(key) + if type is not None: + values = [type(value) for value in values] + return values + + +class AsyncBytesIO: + """An async wrapper for BytesIO.""" + def __init__(self, data): + self.stream = io.BytesIO(data) + + async def read(self, n=-1): + return self.stream.read(n) + + async def readline(self): # pragma: no cover + return self.stream.readline() + + async def readexactly(self, n): # pragma: no cover + return self.stream.read(n) + + async def readuntil(self, separator=b'\n'): # pragma: no cover + return self.stream.readuntil(separator=separator) + + async def awrite(self, data): # pragma: no cover + return self.stream.write(data) + + async def aclose(self): # pragma: no cover + pass + + +class Request: + """An HTTP request.""" + #: Specify the maximum payload size that is accepted. Requests with larger + #: payloads will be rejected with a 413 status code. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed + max_content_length = 16 * 1024 + + #: Specify the maximum payload size that can be stored in ``body``. + #: Requests with payloads that are larger than this size and up to + #: ``max_content_length`` bytes will be accepted, but the application will + #: only be able to access the body of the request by reading from + #: ``stream``. Set to 0 if you always access the body as a stream. + #: + #: Example:: + #: + #: Request.max_body_length = 4 * 1024 # up to 4KB bodies read + max_body_length = 16 * 1024 + + #: Specify the maximum length allowed for a line in the request. Requests + #: with longer lines will not be correctly interpreted. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_readline = 16 * 1024 # 16KB lines allowed + max_readline = 2 * 1024 + + class G: + pass + + def __init__(self, app, client_addr, method, url, http_version, headers, + body=None, stream=None, sock=None, url_prefix='', + subapp=None, scheme=None, route=None): + #: The application instance to which this request belongs. + self.app = app + #: The address of the client, as a tuple (host, port). + self.client_addr = client_addr + #: The HTTP method of the request. + self.method = method + #: The scheme of the request, either `http` or `https`. + self.scheme = scheme or 'http' + #: The request URL, including the path and query string, but not the + #: scheme or the host, which is available in the ``Host`` header. + self.url = url + #: The URL prefix, if the endpoint comes from a mounted + #: sub-application, or else ''. + self.url_prefix = url_prefix + #: The sub-application instance, or `None` if this isn't a mounted + #: endpoint. + self.subapp = subapp + #: The route function that handles this request. + self.route = route + #: The path portion of the URL. + self.path = url + #: The query string portion of the URL. + self.query_string = None + #: The parsed query string, as a + #: :class:`MultiDict ` object. + self.args = {} + #: A dictionary with the headers included in the request. + self.headers = headers + #: A dictionary with the cookies included in the request. + self.cookies = {} + #: The parsed ``Content-Length`` header. + self.content_length = 0 + #: The parsed ``Content-Type`` header. + self.content_type = None + #: A general purpose container for applications to store data during + #: the life of the request. + self.g = Request.G() + + self.http_version = http_version + if '?' in self.path: + self.path, self.query_string = self.path.split('?', 1) + self.args = self._parse_urlencoded(self.query_string) + + if 'Content-Length' in self.headers: + self.content_length = int(self.headers['Content-Length']) + if 'Content-Type' in self.headers: + self.content_type = self.headers['Content-Type'] + if 'Cookie' in self.headers: + for cookie in self.headers['Cookie'].split(';'): + c = cookie.strip().split('=', 1) + self.cookies[c[0]] = c[1] if len(c) > 1 else '' + + self._body = body + self.body_used = False + self._stream = stream + self.sock = sock + self._json = None + self._form = None + self._files = None + self.after_request_handlers = [] + + @staticmethod + async def create(app, client_reader, client_writer, client_addr, + scheme=None): + """Create a request object. + + :param app: The Microdot application instance. + :param client_reader: An input stream from where the request data can + be read. + :param client_writer: An output stream where the response data can be + written. + :param client_addr: The address of the client, as a tuple. + :param scheme: The scheme of the request, either 'http' or 'https'. + + This method is a coroutine. It returns a newly created ``Request`` + object. + """ + # request line + line = (await Request._safe_readline(client_reader)).strip().decode() + if not line: # pragma: no cover + return None + method, url, http_version = line.split() + http_version = http_version.split('/', 1)[1] + + # headers + headers = NoCaseDict() + content_length = 0 + while True: + line = (await Request._safe_readline( + client_reader)).strip().decode() + if line == '': + break + header, value = line.split(':', 1) + value = value.strip() + headers[header] = value + if header.lower() == 'content-length': + content_length = int(value) + + # body + body = b'' + if content_length and content_length <= Request.max_body_length: + body = await client_reader.readexactly(content_length) + stream = None + else: + body = b'' + stream = client_reader + + return Request(app, client_addr, method, url, http_version, headers, + body=body, stream=stream, + sock=(client_reader, client_writer), scheme=scheme) + + def _parse_urlencoded(self, urlencoded): + data = MultiDict() + if len(urlencoded) > 0: # pragma: no branch + if isinstance(urlencoded, str): + for kv in [pair.split('=', 1) + for pair in urlencoded.split('&') if pair]: + data[urldecode(kv[0])] = urldecode(kv[1]) \ + if len(kv) > 1 else '' + elif isinstance(urlencoded, bytes): # pragma: no branch + for kv in [pair.split(b'=', 1) + for pair in urlencoded.split(b'&') if pair]: + data[urldecode(kv[0])] = urldecode(kv[1]) \ + if len(kv) > 1 else b'' + return data + + @property + def body(self): + """The body of the request, as bytes.""" + return self._body + + @property + def stream(self): + """The body of the request, as a bytes stream.""" + if self._stream is None: + self._stream = AsyncBytesIO(self._body) + return self._stream + + @property + def json(self): + """The parsed JSON body, or ``None`` if the request does not have a + JSON body.""" + if self._json is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/json': + return None + self._json = json.loads(self.body.decode()) + return self._json + + @property + def form(self): + """The parsed form submission body, as a + :class:`MultiDict ` object, or ``None`` if the + request does not have a form submission. + + Forms that are URL encoded are processed by default. For multipart + forms to be processed, the + :func:`with_form_data ` + decorator must be added to the route. + """ + if self._form is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/x-www-form-urlencoded': + return None + self._form = self._parse_urlencoded(self.body) + return self._form + + @property + def files(self): + """The files uploaded in the request as a dictionary, or ``None`` if + the request does not have any files. + + The :func:`with_form_data ` + decorator must be added to the route that receives file uploads for + this property to be set. + """ + return self._files + + def after_request(self, f): + """Register a request-specific function to run after the request is + handled. Request-specific after request handlers run at the very end, + after the application's own after request handlers. The function must + take two arguments, the request and response objects. The return value + of the function must be the updated response object. + + Example:: + + @app.route('/') + def index(request): + # register a request-specific after request handler + @req.after_request + def func(request, response): + # ... + return response + + return 'Hello, World!' + + Note that the function is not called if the request handler raises an + exception and an error response is returned instead. + """ + self.after_request_handlers.append(f) + return f + + @staticmethod + async def _safe_readline(stream): + line = (await stream.readline()) + if len(line) > Request.max_readline: + raise ValueError('line too long') + return line + + +class Response: + """An HTTP response class. + + :param body: The body of the response. If a dictionary or list is given, + a JSON formatter is used to generate the body. If a file-like + object or an async generator is given, a streaming response is + used. If a string is given, it is encoded from UTF-8. Else, + the body should be a byte sequence. + :param status_code: The numeric HTTP status code of the response. The + default is 200. + :param headers: A dictionary of headers to include in the response. + :param reason: A custom reason phrase to add after the status code. The + default is "OK" for responses with a 200 status code and + "N/A" for any other status codes. + """ + types_map = { + 'css': 'text/css', + 'gif': 'image/gif', + 'html': 'text/html', + 'jpg': 'image/jpeg', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'txt': 'text/plain', + 'svg': 'image/svg+xml', + } + + send_file_buffer_size = 1024 + + #: The content type to use for responses that do not explicitly define a + #: ``Content-Type`` header. + default_content_type = 'text/plain' + + #: The default cache control max age used by :meth:`send_file`. A value + #: of ``None`` means that no ``Cache-Control`` header is added. + default_send_file_max_age = None + + #: Special response used to signal that a response does not need to be + #: written to the client. Used to exit WebSocket connections cleanly. + already_handled = None + + def __init__(self, body='', status_code=200, headers=None, reason=None): + if body is None and status_code == 200: + body = '' + status_code = 204 + self.status_code = status_code + self.headers = NoCaseDict(headers or {}) + self.reason = reason + if isinstance(body, (dict, list)): + body = json.dumps(body) + self.headers['Content-Type'] = 'application/json; charset=UTF-8' + if isinstance(body, str): + self.body = body.encode() + else: + # this applies to bytes, file-like objects or generators + self.body = body + self.is_head = False + + def set_cookie(self, cookie, value, path=None, domain=None, expires=None, + max_age=None, secure=False, http_only=False, + partitioned=False): + """Add a cookie to the response. + + :param cookie: The cookie's name. + :param value: The cookie's value. + :param path: The cookie's path. + :param domain: The cookie's domain. + :param expires: The cookie expiration time, as a ``datetime`` object + or a correctly formatted string. + :param max_age: The cookie's ``Max-Age`` value. + :param secure: The cookie's ``secure`` flag. + :param http_only: The cookie's ``HttpOnly`` flag. + :param partitioned: Whether the cookie is partitioned. + """ + http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) + if path: + http_cookie += '; Path=' + path + if domain: + http_cookie += '; Domain=' + domain + if expires: + if isinstance(expires, str): + http_cookie += '; Expires=' + expires + else: # pragma: no cover + http_cookie += '; Expires=' + time.strftime( + '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) + if max_age is not None: + http_cookie += '; Max-Age=' + str(max_age) + if secure: + http_cookie += '; Secure' + if http_only: + http_cookie += '; HttpOnly' + if partitioned: + http_cookie += '; Partitioned' + if 'Set-Cookie' in self.headers: + self.headers['Set-Cookie'].append(http_cookie) + else: + self.headers['Set-Cookie'] = [http_cookie] + + def delete_cookie(self, cookie, **kwargs): + """Delete a cookie. + + :param cookie: The cookie's name. + :param kwargs: Any cookie options and flags supported by + :meth:`set_cookie() `. + Values given for ``expires`` and ``max_age`` are + ignored. + """ + kwargs.pop('expires', None) + kwargs.pop('max_age', None) + self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', + max_age=0, **kwargs) + + def complete(self): + if isinstance(self.body, bytes) and \ + 'Content-Length' not in self.headers: + self.headers['Content-Length'] = str(len(self.body)) + if 'Content-Type' not in self.headers: + self.headers['Content-Type'] = self.default_content_type + if 'charset=' not in self.headers['Content-Type']: + self.headers['Content-Type'] += '; charset=UTF-8' + + async def write(self, stream): + self.complete() + + try: + # status code + reason = self.reason if self.reason is not None else \ + ('OK' if self.status_code == 200 else 'N/A') + await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( + status_code=self.status_code, reason=reason).encode()) + + # headers + for header, value in self.headers.items(): + values = value if isinstance(value, list) else [value] + for value in values: + await stream.awrite('{header}: {value}\r\n'.format( + header=header, value=value).encode()) + await stream.awrite(b'\r\n') + + # body + if not self.is_head: + iter = self.body_iter() + async for body in iter: + if isinstance(body, str): # pragma: no cover + body = body.encode() + try: + await stream.awrite(body) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + if hasattr(iter, 'aclose'): + await iter.aclose() + raise + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() + + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + pass + else: + raise + + def body_iter(self): + if hasattr(self.body, '__anext__'): + # response body is an async generator + return self.body + + response = self + + class iter: + ITER_UNKNOWN = 0 + ITER_SYNC_GEN = 1 + ITER_FILE_OBJ = 2 + ITER_NO_BODY = -1 + + def __aiter__(self): + if response.body: + self.i = self.ITER_UNKNOWN # need to determine type + else: + self.i = self.ITER_NO_BODY + return self + + async def __anext__(self): + if self.i == self.ITER_NO_BODY: + await self.aclose() + raise StopAsyncIteration + if self.i == self.ITER_UNKNOWN: + if hasattr(response.body, 'read'): + self.i = self.ITER_FILE_OBJ + elif hasattr(response.body, '__next__'): + self.i = self.ITER_SYNC_GEN + return next(response.body) + else: + self.i = self.ITER_NO_BODY + return response.body + elif self.i == self.ITER_SYNC_GEN: + try: + return next(response.body) + except StopIteration: + await self.aclose() + raise StopAsyncIteration + buf = response.body.read(response.send_file_buffer_size) + if iscoroutine(buf): # pragma: no cover + buf = await buf + if len(buf) < response.send_file_buffer_size: + self.i = self.ITER_NO_BODY + return buf + + async def aclose(self): + if hasattr(response.body, 'close'): + result = response.body.close() + if iscoroutine(result): # pragma: no cover + await result + + return iter() + + @classmethod + def redirect(cls, location, status_code=302): + """Return a redirect response. + + :param location: The URL to redirect to. + :param status_code: The 3xx status code to use for the redirect. The + default is 302. + """ + if '\x0d' in location or '\x0a' in location: + raise ValueError('invalid redirect URL') + return cls(status_code=status_code, headers={'Location': location}) + + @classmethod + def send_file(cls, filename, status_code=200, content_type=None, + stream=None, max_age=None, compressed=False, + file_extension=''): + """Send file contents in a response. + + :param filename: The filename of the file. + :param status_code: The 3xx status code to use for the redirect. The + default is 200. + :param content_type: The ``Content-Type`` header to use in the + response. If omitted, it is generated + automatically from the file extension of the + ``filename`` parameter. + :param stream: A file-like object to read the file contents from. If + a stream is given, the ``filename`` parameter is only + used when generating the ``Content-Type`` header. + :param max_age: The ``Cache-Control`` header's ``max-age`` value in + seconds. If omitted, the value of the + :attr:`Response.default_send_file_max_age` attribute is + used. + :param compressed: Whether the file is compressed. If ``True``, the + ``Content-Encoding`` header is set to ``gzip``. A + string with the header value can also be passed. + Note that when using this option the file must have + been compressed beforehand. This option only sets + the header. + :param file_extension: A file extension to append to the ``filename`` + parameter when opening the file, including the + dot. The extension given here is not considered + when generating the ``Content-Type`` header. + + Security note: The filename is assumed to be trusted. Never pass + filenames provided by the user without validating and sanitizing them + first. + """ + if content_type is None: + if compressed and filename.endswith('.gz'): + ext = filename[:-3].split('.')[-1] + else: + ext = filename.split('.')[-1] + if ext in Response.types_map: + content_type = Response.types_map[ext] + else: + content_type = 'application/octet-stream' + headers = {'Content-Type': content_type} + + if max_age is None: + max_age = cls.default_send_file_max_age + if max_age is not None: + headers['Cache-Control'] = 'max-age={}'.format(max_age) + + if compressed: + headers['Content-Encoding'] = compressed \ + if isinstance(compressed, str) else 'gzip' + + f = stream or open(filename + file_extension, 'rb') + return cls(body=f, status_code=status_code, headers=headers) + + +class URLPattern(): + """A class that represents the URL pattern for a route. + + :param url_pattern: The route URL pattern, which can include static and + dynamic path segments. Dynamic segments are enclosed in + ``<`` and ``>``. The type of the segment can be given + as a prefix, separated from the name with a colon. + Supported types are ``string`` (the default), + ``int`` and ``path``. Custom types can be registered + using the :meth:`URLPattern.register_type` method. + """ + + segment_patterns = { + 'string': '/([^/]+)', + 'int': '/(-?\\d+)', + 'path': '/(.+)', + } + segment_parsers = { + 'int': lambda value: int(value), + } + + @classmethod + def register_type(cls, type_name, pattern='[^/]+', parser=None): + """Register a new URL segment type. + + :param type_name: The name of the segment type to register. + :param pattern: The regular expression pattern to use when matching + this segment type. If not given, a default matcher for + a single path segment is used. + :param parser: A callable that will be used to parse and transform the + value of the segment. If omitted, the value is returned + as a string. + """ + cls.segment_patterns[type_name] = '/({})'.format(pattern) + cls.segment_parsers[type_name] = parser + + def __init__(self, url_pattern): + self.url_pattern = url_pattern + self.segments = [] + self.regex = None + + def compile(self): + """Generate a regular expression for the URL pattern. + + This method is automatically invoked the first time the URL pattern is + matched against a path. + """ + pattern = '' + for segment in self.url_pattern.lstrip('/').split('/'): + if segment and segment[0] == '<': + if segment[-1] != '>': + raise ValueError('invalid URL pattern') + segment = segment[1:-1] + if ':' in segment: + type_, name = segment.rsplit(':', 1) + else: + type_ = 'string' + name = segment + parser = None + if type_.startswith('re:'): + pattern += '/({pattern})'.format(pattern=type_[3:]) + else: + if type_ not in self.segment_patterns: + raise ValueError('invalid URL segment type') + pattern += self.segment_patterns[type_] + parser = self.segment_parsers.get(type_) + self.segments.append({'parser': parser, 'name': name, + 'type': type_}) + else: + pattern += '/' + segment + self.segments.append({'parser': None}) + self.regex = re.compile('^' + pattern + '$') + return self.regex + + def match(self, path): + """Match a path against the URL pattern. + + Returns a dictionary with the values of all dynamic path segments if a + matche is found, or ``None`` if the path does not match this pattern. + """ + args = {} + g = (self.regex or self.compile()).match(path) + if not g: + return + i = 1 + for segment in self.segments: + if 'name' not in segment: + continue + arg = g.group(i) + if segment['parser']: + arg = self.segment_parsers[segment['type']](arg) + if arg is None: + return + args[segment['name']] = arg + i += 1 + return args + + def __repr__(self): # pragma: no cover + return 'URLPattern: {}'.format(self.url_pattern) + + +class HTTPException(Exception): + def __init__(self, status_code, reason=None): + self.status_code = status_code + self.reason = reason or str(status_code) + ' error' + + def __repr__(self): # pragma: no cover + return 'HTTPException: {}'.format(self.status_code) + + +class Microdot: + """An HTTP application class. + + This class implements an HTTP application instance and is heavily + influenced by the ``Flask`` class of the Flask framework. It is typically + declared near the start of the main application script. + + Example:: + + from microdot import Microdot + + app = Microdot() + """ + + def __init__(self): + self.url_map = [] + self.before_request_handlers = [] + self.after_request_handlers = [] + self.after_error_request_handlers = [] + self.error_handlers = {} + self.options_handler = self.default_options_handler + self.ssl = False + self.debug = False + self.server = None + + def route(self, url_pattern, methods=None): + """Decorator that is used to register a function as a request handler + for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + :param methods: The list of HTTP methods to be handled by the + decorated function. If omitted, only ``GET`` requests + are handled. + + The URL pattern can be a static path (for example, ``/users`` or + ``/api/invoices/search``) or a path with dynamic components enclosed + in ``<`` and ``>`` (for example, ``/users/`` or + ``/invoices//products``). Dynamic path components can also + include a type prefix, separated from the name with a colon (for + example, ``/users/``). The type can be ``string`` (the + default), ``int``, ``path`` or ``re:[regular-expression]``. + + The first argument of the decorated function must be + the request object. Any path arguments that are specified in the URL + pattern are passed as keyword arguments. The return value of the + function must be a :class:`Response` instance, or the arguments to + be passed to this class. + + Example:: + + @app.route('/') + def index(request): + return 'Hello, world!' + """ + def decorated(f): + self.url_map.append( + ([m.upper() for m in (methods or ['GET'])], + URLPattern(url_pattern), f, '', None)) + return f + return decorated + + def get(self, url_pattern): + """Decorator that is used to register a function as a ``GET`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['GET']``. + + Example:: + + @app.get('/users/') + def get_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['GET']) + + def post(self, url_pattern): + """Decorator that is used to register a function as a ``POST`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the``route`` decorator with + ``methods=['POST']``. + + Example:: + + @app.post('/users') + def create_user(request): + # ... + """ + return self.route(url_pattern, methods=['POST']) + + def put(self, url_pattern): + """Decorator that is used to register a function as a ``PUT`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PUT']``. + + Example:: + + @app.put('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PUT']) + + def patch(self, url_pattern): + """Decorator that is used to register a function as a ``PATCH`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PATCH']``. + + Example:: + + @app.patch('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PATCH']) + + def delete(self, url_pattern): + """Decorator that is used to register a function as a ``DELETE`` + request handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['DELETE']``. + + Example:: + + @app.delete('/users/') + def delete_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['DELETE']) + + def before_request(self, f): + """Decorator to register a function to run before each request is + handled. The decorated function must take a single argument, the + request object. + + Example:: + + @app.before_request + def func(request): + # ... + """ + self.before_request_handlers.append(f) + return f + + def after_request(self, f): + """Decorator to register a function to run after each request is + handled. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. + + Example:: + + @app.after_request + def func(request, response): + # ... + return response + """ + self.after_request_handlers.append(f) + return f + + def after_error_request(self, f): + """Decorator to register a function to run after an error response is + generated. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. The handler is invoked for error responses + generated by Microdot, as well as those returned by application-defined + error handlers. + + Example:: + + @app.after_error_request + def func(request, response): + # ... + return response + """ + self.after_error_request_handlers.append(f) + return f + + def errorhandler(self, status_code_or_exception_class): + """Decorator to register a function as an error handler. Error handler + functions for numeric HTTP status codes must accept a single argument, + the request object. Error handler functions for Python exceptions + must accept two arguments, the request object and the exception + object. + + :param status_code_or_exception_class: The numeric HTTP status code or + Python exception class to + handle. + + Examples:: + + @app.errorhandler(404) + def not_found(request): + return 'Not found' + + @app.errorhandler(RuntimeError) + def runtime_error(request, exception): + return 'Runtime error' + """ + def decorated(f): + self.error_handlers[status_code_or_exception_class] = f + return f + return decorated + + def mount(self, subapp, url_prefix='', local=False): + """Mount a sub-application, optionally under the given URL prefix. + + :param subapp: The sub-application to mount. + :param url_prefix: The URL prefix to mount the application under. + :param local: When set to ``True``, the before, after and error request + handlers only apply to endpoints defined in the + sub-application. When ``False``, they apply to the entire + application. The default is ``False``. + """ + for methods, pattern, handler, _prefix, _subapp in subapp.url_map: + self.url_map.append( + (methods, URLPattern(url_prefix + pattern.url_pattern), + handler, url_prefix + _prefix, _subapp or subapp)) + if not local: + for handler in subapp.before_request_handlers: + self.before_request_handlers.append(handler) + subapp.before_request_handlers = [] + for handler in subapp.after_request_handlers: + self.after_request_handlers.append(handler) + subapp.after_request_handlers = [] + for handler in subapp.after_error_request_handlers: + self.after_error_request_handlers.append(handler) + subapp.after_error_request_handlers = [] + for status_code, handler in subapp.error_handlers.items(): + self.error_handlers[status_code] = handler + subapp.error_handlers = {} + + @staticmethod + def abort(status_code, reason=None): + """Abort the current request and return an error response with the + given status code. + + :param status_code: The numeric status code of the response. + :param reason: The reason for the response, which is included in the + response body. + + Example:: + + from microdot import abort + + @app.route('/users/') + def get_user(id): + user = get_user_by_id(id) + if user is None: + abort(404) + return user.to_dict() + """ + raise HTTPException(status_code, reason) + + async def start_server(self, host='0.0.0.0', port=5000, debug=False, + ssl=None): + """Start the Microdot web server as a coroutine. This coroutine does + not normally return, as the server enters an endless listening loop. + The :func:`shutdown` function provides a method for terminating the + server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + This method is a coroutine. + + Example:: + + import asyncio + from microdot import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + async def main(): + await app.start_server(debug=True) + + asyncio.run(main()) + """ + self.ssl = ssl + self.debug = debug + + async def serve(reader, writer): + if not hasattr(writer, 'awrite'): # pragma: no cover + # CPython provides the awrite and aclose methods in 3.8+ + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + + await self.handle_request(reader, writer) + + if self.debug: # pragma: no cover + print('Starting async server on {host}:{port}...'.format( + host=host, port=port)) + + try: + self.server = await asyncio.start_server(serve, host, port, + ssl=ssl) + except TypeError: # pragma: no cover + self.server = await asyncio.start_server(serve, host, port) + + while True: + try: + if hasattr(self.server, 'serve_forever'): # pragma: no cover + try: + await self.server.serve_forever() + except asyncio.CancelledError: + pass + await self.server.wait_closed() + break + except AttributeError: # pragma: no cover + # the task hasn't been initialized in the server object yet + # wait a bit and try again + await asyncio.sleep(0.1) + + def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): + """Start the web server. This function does not normally return, as + the server enters an endless listening loop. The :func:`shutdown` + function provides a method for terminating the server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + Example:: + + from microdot import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + app.run(debug=True) + """ + asyncio.run(self.start_server(host=host, port=port, debug=debug, + ssl=ssl)) # pragma: no cover + + def shutdown(self): + """Request a server shutdown. The server will then exit its request + listening loop and the :func:`run` function will return. This function + can be safely called from a route handler, as it only schedules the + server to terminate as soon as the request completes. + + Example:: + + @app.route('/shutdown') + def shutdown(request): + request.app.shutdown() + return 'The server is shutting down...' + """ + self.server.close() + + def find_route(self, req): + method = req.method.upper() + if method == 'OPTIONS' and self.options_handler: + return self.options_handler(req), '', None + if method == 'HEAD': + method = 'GET' + f = 404 + p = '' + s = None + for route_methods, route_pattern, route_handler, url_prefix, subapp \ + in self.url_map: + req.url_args = route_pattern.match(req.path) + if req.url_args is not None: + p = url_prefix + s = subapp + if method in route_methods: + f = route_handler + break + else: + f = 405 + return f, p, s + + def default_options_handler(self, req): + allow = [] + for route_methods, route_pattern, _, _, _ in self.url_map: + if route_pattern.match(req.path) is not None: + allow.extend(route_methods) + if 'GET' in allow: + allow.append('HEAD') + allow.append('OPTIONS') + return {'Allow': ', '.join(allow)} + + async def handle_request(self, reader, writer): + req = None + try: + req = await Request.create(self, reader, writer, + writer.get_extra_info('peername')) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + except Exception as exc: # pragma: no cover + print_exception(exc) + + res = await self.dispatch_request(req) + try: + if res != Response.already_handled: # pragma: no branch + await res.write(writer) + await writer.aclose() + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + if self.debug and req: # pragma: no cover + print('{method} {path} {status_code}'.format( + method=req.method, path=req.path, + status_code=res.status_code)) + + def get_request_handlers(self, req, attr, local_first=True): + handlers = getattr(self, attr + '_handlers') + local_handlers = getattr(req.subapp, attr + '_handlers') \ + if req and req.subapp else [] + return local_handlers + handlers if local_first \ + else handlers + local_handlers + + async def error_response(self, req, status_code, reason=None): + if req and req.subapp and status_code in req.subapp.error_handlers: + return await invoke_handler( + req.subapp.error_handlers[status_code], req) + elif status_code in self.error_handlers: + return await invoke_handler(self.error_handlers[status_code], req) + return reason or 'N/A', status_code + + async def dispatch_request(self, req): + after_request_handled = False + if req: + if req.content_length > req.max_content_length: + # the request body is larger than allowed + res = await self.error_response(req, 413, 'Payload too large') + else: + # find the route in the app's URL map + f, req.url_prefix, req.subapp = self.find_route(req) + + try: + res = None + if callable(f): + req.route = f + + # invoke the before request handlers + for handler in self.get_request_handlers( + req, 'before_request', False): + res = await invoke_handler(handler, req) + if res: + break + + # invoke the endpoint handler + if res is None: + res = await invoke_handler(f, req, **req.url_args) + + # process the response + if isinstance(res, int): + # an integer response is taken as a status code + # with an empty body + res = '', res + if isinstance(res, tuple): + # handle a tuple response + if isinstance(res[0], int): + # a tuple that starts with an int has an empty + # body + res = ('', res[0], + res[1] if len(res) > 1 else {}) + body = res[0] + if isinstance(res[1], int): + # extract the status code and headers (if + # available) + status_code = res[1] + headers = res[2] if len(res) > 2 else {} + else: + # if the status code is missing, assume 200 + status_code = 200 + headers = res[1] + res = Response(body, status_code, headers) + elif not isinstance(res, Response): + # any other response types are wrapped in a + # Response object + res = Response(res) + + # invoke the after request handlers + for handler in self.get_request_handlers( + req, 'after_request', True): + res = await invoke_handler( + handler, req, res) or res + for handler in req.after_request_handlers: + res = await invoke_handler( + handler, req, res) or res + after_request_handled = True + elif isinstance(f, dict): + # the response from an OPTIONS request is a dict with + # headers + res = Response(headers=f) + else: + # if the route is not found, return a 404 or 405 + # response as appropriate + res = await self.error_response(req, f, 'Not found') + except HTTPException as exc: + # an HTTP exception was raised while handling this request + res = await self.error_response(req, exc.status_code, + exc.reason) + except Exception as exc: + # an unexpected exception was raised while handling this + # request + print_exception(exc) + + # invoke the error handler for the exception class if one + # exists + handler = None + res = None + if req.subapp and exc.__class__ in \ + req.subapp.error_handlers: + handler = req.subapp.error_handlers[exc.__class__] + elif exc.__class__ in self.error_handlers: + handler = self.error_handlers[exc.__class__] + else: + # walk up the exception class hierarchy to try to find + # a handler + for c in mro(exc.__class__)[1:]: + if req.subapp and c in req.subapp.error_handlers: + handler = req.subapp.error_handlers[c] + break + elif c in self.error_handlers: + handler = self.error_handlers[c] + break + if handler: + try: + res = await invoke_handler(handler, req, exc) + except Exception as exc2: # pragma: no cover + print_exception(exc2) + if res is None: + # if there is still no response, issue a 500 error + res = await self.error_response( + req, 500, 'Internal server error') + else: + # if the request could not be parsed, issue a 400 error + res = await self.error_response(req, 400, 'Bad request') + if isinstance(res, tuple): + res = Response(*res) + elif not isinstance(res, Response): + res = Response(res) + if not after_request_handled: + # if the request did not finish due to an error, invoke the after + # error request handler + for handler in self.get_request_handlers( + req, 'after_error_request', True): + res = await invoke_handler( + handler, req, res) or res + res.is_head = (req and req.method == 'HEAD') + return res + + +Response.already_handled = Response() + +abort = Microdot.abort +redirect = Response.redirect +send_file = Response.send_file diff --git a/rinkhals-webui/src/microdot/multipart.py b/rinkhals-webui/src/microdot/multipart.py new file mode 100644 index 0000000..9d7a720 --- /dev/null +++ b/rinkhals-webui/src/microdot/multipart.py @@ -0,0 +1,298 @@ +import os +from random import choice +from microdot import abort, iscoroutine, AsyncBytesIO +from microdot.helpers import wraps + + +class FormDataIter: + """Asynchronous iterator that parses a ``multipart/form-data`` body and + returns form fields and files as they are parsed. + + :param request: the request object to parse. + + Example usage:: + + from microdot.multipart import FormDataIter + + @app.post('/upload') + async def upload(request): + async for name, value in FormDataIter(request): + print(name, value) + + The iterator returns no values when the request has a content type other + than ``multipart/form-data``. For a file field, the returned value is of + type :class:`FileUpload`, which supports the + :meth:`read() ` and :meth:`save() ` + methods. Values for regular fields are provided as strings. + + The request body is read efficiently in chunks of size + :attr:`buffer_size `. On iterations in which a + file field is encountered, the file must be consumed before moving on to + the next iteration, as the internal stream stored in ``FileUpload`` + instances is invalidated at the end of the iteration. + """ + #: The size of the buffer used to read chunks of the request body. This + #: size must be large enough to hold at least one complete header or + #: boundary line, so it is not recommended to lower it, but it can be made + #: higher to improve performance at the expense of RAM. + buffer_size = 256 + + def __init__(self, request): + self.request = request + self.buffer = None + try: + mimetype, boundary = request.content_type.rsplit('; boundary=', 1) + except ValueError: + return # not a multipart request + if mimetype.split(';', 1)[0] == \ + 'multipart/form-data': # pragma: no branch + self.boundary = b'--' + boundary.encode() + self.extra_size = len(boundary) + 4 + self.buffer = b'' + + def __aiter__(self): + return self + + async def __anext__(self): + if self.buffer is None: + raise StopAsyncIteration + + # make sure we have consumed the previous entry + while await self._read_buffer(self.buffer_size) != b'': + pass + + # make sure we are at a boundary + await self._fill_buffer() + s = self.buffer.split(self.boundary, 1) + if len(s) != 2 or s[0] != b'': + abort(400) # pragma: no cover + self.buffer = s[1] + if self.buffer[:2] == b'--': + # we have reached the end + raise StopAsyncIteration + elif self.buffer[:2] != b'\r\n': + abort(400) # pragma: no cover + self.buffer = self.buffer[2:] + + # parse the headers of this part + name = '' + filename = None + content_type = None + while True: + await self._fill_buffer() + lines = self.buffer.split(b'\r\n', 1) + if len(lines) != 2: + abort(400) # pragma: no cover + line, self.buffer = lines + if line == b'': + # we reached the end of the headers + break + header, value = line.decode().split(':', 1) + header = header.lower() + value = value.strip() + if header == 'content-disposition': + parts = value.split(';') + if len(parts) < 2 or parts[0] != 'form-data': + abort(400) # pragma: no cover + for part in parts[1:]: + part = part.strip() + if part.startswith('name="'): + name = part[6:-1] + elif part.startswith('filename="'): # pragma: no branch + filename = part[10:-1] + elif header == 'content-type': # pragma: no branch + content_type = value + + if filename is None: + # this is a regular form field, so we read the value + value = b'' + while True: + v = await self._read_buffer(self.buffer_size) + value += v + if len(v) < self.buffer_size: # pragma: no branch + break + return name, value.decode() + return name, FileUpload(filename, content_type, self._read_buffer) + + async def _fill_buffer(self): + if self.buffer[-len(self.boundary) - 4:] == self.boundary + b'--\r\n': + # we have reached the end of the body + return + self.buffer += await self.request.stream.read( + self.buffer_size + self.extra_size - len(self.buffer)) + + async def _read_buffer(self, n=-1): + data = b'' + while n == -1 or len(data) < n: + await self._fill_buffer() + s = self.buffer.split(self.boundary, 1) + data += s[0][:n] if n != -1 else s[0] + self.buffer = s[0][n:] if n != -1 else b'' + if len(s) == 2: # pragma: no branch + # the end of this part is in the buffer + if len(self.buffer) < 2: + # we have read all the way to the end of this part + data = data[:-(2 - len(self.buffer))] # remove last "\r\n" + self.buffer += self.boundary + s[1] + return data + return data + + +class FileUpload: + """Class that represents an uploaded file. + + :param filename: the name of the uploaded file. + :param content_type: the content type of the uploaded file. + :param read: a coroutine that reads from the uploaded file's stream. + + An uploaded file can be read from the stream using the :meth:`read()` + method or saved to a file using the :meth:`save()` method. + + Instances of this class do not normally need to be created directly. + """ + #: The size at which the file is copied to a temporary file. + max_memory_size = 1024 + + def __init__(self, filename, content_type, read): + self.filename = filename + self.content_type = content_type + self._read = read + self._close = None + + async def read(self, n=-1): + """Read up to ``n`` bytes from the uploaded file's stream. + + :param n: the maximum number of bytes to read. If ``n`` is -1 or not + given, the entire file is read. + """ + return await self._read(n) + + async def save(self, path_or_file): + """Save the uploaded file to the given path or file object. + + :param path_or_file: the path to save the file to, or a file object + to which the file is to be written. + + The file is read and written in chunks of size + :attr:`FormDataIter.buffer_size`. + """ + if isinstance(path_or_file, str): + f = open(path_or_file, 'wb') + else: + f = path_or_file + while True: + data = await self.read(FormDataIter.buffer_size) + if not data: + break + f.write(data) + if f != path_or_file: + f.close() + + async def copy(self, max_memory_size=None): + """Copy the uploaded file to a temporary file, to allow the parsing of + the multipart form to continue. + + :param max_memory_size: the maximum size of the file to keep in memory. + If not given, then the class attribute of the + same name is used. + """ + max_memory_size = max_memory_size or FileUpload.max_memory_size + buffer = await self.read(max_memory_size) + if len(buffer) < max_memory_size: + f = AsyncBytesIO(buffer) + self._read = f.read + return self + + # create a temporary file + while True: + tmpname = "".join([ + choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') + for _ in range(12) + ]) + try: + f = open(tmpname, 'x+b') + except OSError as e: # pragma: no cover + if e.errno == 17: + # EEXIST + continue + elif e.errno == 2: + # ENOENT + # some MicroPython platforms do not support mode "x" + f = open(tmpname, 'w+b') + if f.read(1) != b'': + f.close() + continue + else: + raise + break + f.write(buffer) + await self.save(f) + f.seek(0) + + async def read(n=-1): + return f.read(n) + + async def close(): + f.close() + os.remove(tmpname) + + self._read = read + self._close = close + return self + + async def close(self): + """Close an open file. + + This method must be called to free memory or temporary files created by + the ``copy()`` method. + + Note that when using the ``@with_form_data`` decorator this method is + called automatically when the request ends. + """ + if self._close: + await self._close() + self._close = None + + +def with_form_data(f): + """Decorator that parses a ``multipart/form-data`` body and updates the + request object with the parsed form fields and files. + + Example usage:: + + from microdot.multipart import with_form_data + + @app.post('/upload') + @with_form_data + async def upload(request): + print('form fields:', request.form) + print('files:', request.files) + + Note: this decorator calls the :meth:`FileUpload.copy() + ` method on all uploaded files, so that + the request can be parsed in its entirety. The files are either copied to + memory or a temporary file, depending on their size. The temporary files + are automatically deleted when the request ends. + """ + @wraps(f) + async def wrapper(request, *args, **kwargs): + form = {} + files = {} + async for name, value in FormDataIter(request): + if isinstance(value, FileUpload): + files[name] = await value.copy() + else: + form[name] = value + if form or files: + request._form = form + request._files = files + try: + ret = f(request, *args, **kwargs) + if iscoroutine(ret): + ret = await ret + finally: + if request.files: + for file in request.files.values(): + await file.close() + return ret + return wrapper diff --git a/rinkhals-webui/src/microdot/pyrightconfig.json b/rinkhals-webui/src/microdot/pyrightconfig.json new file mode 100644 index 0000000..1a1a513 --- /dev/null +++ b/rinkhals-webui/src/microdot/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../pyrightconfig.json", + "typeCheckingMode": "off" +} diff --git a/rinkhals-webui/src/microdot/session.py b/rinkhals-webui/src/microdot/session.py new file mode 100644 index 0000000..041b290 --- /dev/null +++ b/rinkhals-webui/src/microdot/session.py @@ -0,0 +1,159 @@ +import jwt +from microdot.microdot import invoke_handler +from microdot.helpers import wraps + + +class SessionDict(dict): + """A session dictionary. + + The session dictionary is a standard Python dictionary that has been + extended with convenience ``save()`` and ``delete()`` methods. + """ + def __init__(self, request, session_dict): + super().__init__(session_dict) + self.request = request + + def save(self): + """Update the session cookie.""" + self.request.app._session.update(self.request, self) + + def delete(self): + """Delete the session cookie.""" + self.request.app._session.delete(self.request) + + +class Session: + """Session handling + + :param app: The application instance. + :param secret_key: The secret key, as a string or bytes object. + :param cookie_options: A dictionary with cookie options to pass as + arguments to :meth:`Response.set_cookie() + `. + """ + secret_key = None + + def __init__(self, app=None, secret_key=None, cookie_options=None): + self.secret_key = secret_key + self.cookie_options = cookie_options or {} + if app is not None: + self.initialize(app) + + def initialize(self, app, secret_key=None, cookie_options=None): + if secret_key is not None: + self.secret_key = secret_key + if cookie_options is not None: + self.cookie_options = cookie_options + if 'path' not in self.cookie_options: + self.cookie_options['path'] = '/' + if 'http_only' not in self.cookie_options: + self.cookie_options['http_only'] = True + app._session = self + + def get(self, request): + """Retrieve the user session. + + :param request: The client request. + + The return value is a session dictionary with the data stored in the + user's session, or ``{}`` if the session data is not available or + invalid. + """ + if not self.secret_key: + raise ValueError('The session secret key is not configured') + if hasattr(request.g, '_session'): + return request.g._session + session = request.cookies.get('session') + if session is None: + request.g._session = SessionDict(request, {}) + return request.g._session + request.g._session = SessionDict(request, self.decode(session)) + return request.g._session + + def update(self, request, session): + """Update the user session. + + :param request: The client request. + :param session: A dictionary with the update session data for the user. + + Applications would normally not call this method directly, instead they + would use the :meth:`SessionDict.save` method on the session + dictionary, which calls this method. For example:: + + @app.route('/') + @with_session + def index(request, session): + session['foo'] = 'bar' + session.save() + return 'Hello, World!' + + Calling this method adds a cookie with the updated session to the + request currently being processed. + """ + if not self.secret_key: + raise ValueError('The session secret key is not configured') + + encoded_session = self.encode(session) + + @request.after_request + def _update_session(request, response): + response.set_cookie('session', encoded_session, + **self.cookie_options) + return response + + def delete(self, request): + """Remove the user session. + + :param request: The client request. + + Applications would normally not call this method directly, instead they + would use the :meth:`SessionDict.delete` method on the session + dictionary, which calls this method. For example:: + + @app.route('/') + @with_session + def index(request, session): + session.delete() + return 'Hello, World!' + + Calling this method adds a cookie removal header to the request + currently being processed. + """ + @request.after_request + def _delete_session(request, response): + response.delete_cookie('session', **self.cookie_options) + return response + + def encode(self, payload, secret_key=None): + return jwt.encode(payload, secret_key or self.secret_key, + algorithm='HS256') + + def decode(self, session, secret_key=None): + try: + payload = jwt.decode(session, secret_key or self.secret_key, + algorithms=['HS256']) + except jwt.exceptions.PyJWTError: # pragma: no cover + return {} + return payload + + +def with_session(f): + """Decorator that passes the user session to the route handler. + + The session dictionary is passed to the decorated function as an argument + after the request object. Example:: + + @app.route('/') + @with_session + def index(request, session): + return 'Hello, World!' + + Note that the decorator does not save the session. To update the session, + call the :func:`session.save() ` method. + """ + @wraps(f) + async def wrapper(request, *args, **kwargs): + return await invoke_handler( + f, request, request.app._session.get(request), *args, **kwargs) + + return wrapper diff --git a/rinkhals-webui/src/microdot/sse.py b/rinkhals-webui/src/microdot/sse.py new file mode 100644 index 0000000..a27b1ca --- /dev/null +++ b/rinkhals-webui/src/microdot/sse.py @@ -0,0 +1,139 @@ +import asyncio +from microdot.helpers import wraps + +try: + import orjson as json +except ImportError: + import json + + +class SSE: + """Server-Sent Events object. + + An object of this class is sent to handler functions to manage the SSE + connection. + """ + def __init__(self): + self.event = asyncio.Event() + self.queue = [] + + async def send(self, data, event=None, event_id=None, retry=None, + comment=False): + """Send an event to the client. + + :param data: the data to send. It can be given as a string, bytes, dict + or list. Dictionaries and lists are serialized to JSON. + Any other types are converted to string before sending. + :param event: an optional event name, to send along with the data. If + given, it must be a string. + :param event_id: an optional event id, to send along with the data. If + given, it must be a string. + :param retry: an optional reconnection time (in seconds) that the + client should use when the connection is lost. + :param comment: when set to ``True``, the data is sent as a comment + line, and all other parameters are ignored. This is + useful as a heartbeat mechanism that keeps the + connection alive. + """ + if isinstance(data, (dict, list)): + data = json.dumps(data) + if isinstance(data, str): + data = data.encode() + elif not isinstance(data, bytes): + data = str(data).encode() + if comment: + data = b': ' + data + b'\n\n' + else: + data = b'data: ' + data + b'\n\n' + if event_id: + data = b'id: ' + event_id.encode() + b'\n' + data + if event: + data = b'event: ' + event.encode() + b'\n' + data + if retry: + data = b'retry: ' + str(int(retry * 1000)).encode() + b'\n' + \ + data + self.queue.append(data) + self.event.set() + + +def sse_response(request, event_function, *args, **kwargs): + """Return a response object that initiates an event stream. + + :param request: the request object. + :param event_function: an asynchronous function that will send events to + the client. The function is invoked with ``request`` + and an ``sse`` object. The function should use + ``sse.send()`` to send events to the client. + :param args: additional positional arguments to be passed to the response. + :param kwargs: additional keyword arguments to be passed to the response. + + This is a low-level function that can be used to implement a custom SSE + endpoint. In general the :func:`microdot.sse.with_sse` decorator should be + used instead. + """ + sse = SSE() + + async def sse_task_wrapper(): + try: + await event_function(request, sse, *args, **kwargs) + except asyncio.CancelledError: # pragma: no cover + pass + except Exception as exc: + # the SSE task raised an exception so we need to pass it to the + # main route so that it is re-raised there + sse.queue.append(exc) + sse.event.set() + + task = asyncio.create_task(sse_task_wrapper()) + + class sse_loop: + def __aiter__(self): + return self + + async def __anext__(self): + event = None + while sse.queue or not task.done(): + try: + event = sse.queue.pop(0) + break + except IndexError: + await sse.event.wait() + sse.event.clear() + if isinstance(event, Exception): + # if the event is an exception we re-raise it here so that it + # can be handled appropriately + raise event + elif event is None: + raise StopAsyncIteration + return event + + async def aclose(self): + task.cancel() + + return sse_loop(), 200, {'Content-Type': 'text/event-stream'} + + +def with_sse(f): + """Decorator to make a route a Server-Sent Events endpoint. + + This decorator is used to define a route that accepts SSE connections. The + route then receives a sse object as a second argument that it can use to + send events to the client:: + + @app.route('/events') + @with_sse + async def events(request, sse): + # send an unnamed event with string data + await sse.send('hello') + + # send an unnamed event with JSON data + await sse.send({'foo': 'bar'}) + + # send a named event + await sse.send('hello', event='greeting') + """ + @wraps(f) + async def sse_handler(request, *args, **kwargs): + return sse_response(request, f, *args, **kwargs) + + return sse_handler diff --git a/rinkhals-webui/src/microdot/test_client.py b/rinkhals-webui/src/microdot/test_client.py new file mode 100644 index 0000000..dcf5d65 --- /dev/null +++ b/rinkhals-webui/src/microdot/test_client.py @@ -0,0 +1,385 @@ +import asyncio +from microdot.microdot import Request, Response, AsyncBytesIO + +try: + from microdot.websocket import WebSocket +except: # pragma: no cover # noqa: E722 + WebSocket = None + +try: + import orjson as json +except ImportError: + import json + +__all__ = ['TestClient', 'TestResponse'] + + +class TestResponse: + """A response object issued by the Microdot test client.""" + def __init__(self): + #: The numeric status code returned by the server. + self.status_code = None + #: The text reason associated with the status response, such as + #: ``'OK'`` or ``'NOT FOUND'``. Set to ``None`` unless the application + #: explicitly sets it on the response object. + self.reason = None + #: A dictionary with the response headers. + self.headers = {} + #: The body of the response, as a bytes object. + self.body = None + #: The body of the response, decoded to a UTF-8 string. Set to + #: ``None`` if the response cannot be represented as UTF-8 text. + self.text = None + #: The body of the JSON response, decoded to a dictionary or list. Set + #: ``Note`` if the response does not have a JSON payload. + self.json = None + #: The body of the SSE response, decoded to a list of events, each + #: given as a dictionary with a ``data`` key and optionally also + #: ``event`` and ``id`` keys. Set to ``None`` if the response does not + #: have an SSE payload. + self.events = None + + def _initialize_response(self, res): + self.status_code = res.status_code + self.reason = res.reason + self.headers = res.headers + + async def _initialize_body(self, res): + self.body = b'' + iter = res.body_iter() + try: + async for body in iter: # pragma: no branch + if isinstance(body, str): + body = body.encode() + self.body += body + except asyncio.CancelledError: # pragma: no cover + pass + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() + + def _process_text_body(self): + try: + self.text = self.body.decode() + except ValueError: + pass + + def _process_json_body(self): + if 'Content-Type' in self.headers: # pragma: no branch + content_type = self.headers['Content-Type'] + if content_type.split(';')[0] == 'application/json': + self.json = json.loads(self.text) + + def _process_sse_body(self): + if 'Content-Type' in self.headers: # pragma: no branch + content_type = self.headers['Content-Type'] + if content_type.split(';')[0] == 'text/event-stream': + self.events = [] + for sse_event in self.body.split(b'\n\n'): + data = None + event = None + event_id = None + retry = None + for line in sse_event.split(b'\n'): + if line.startswith(b'data:'): + data = line[5:].strip() + elif line.startswith(b'event:'): + event = line[6:].strip().decode() + elif line.startswith(b'id:'): + event_id = line[3:].strip().decode() + elif line.startswith(b'retry:'): + retry = int(line[7:].strip()) / 1000 + if data: + data_json = None + try: + data_json = json.loads(data) + except ValueError: + pass + self.events.append({ + 'data': data, 'data_json': data_json, + 'event': event, 'event_id': event_id, + 'retry': retry}) + + @classmethod + async def create(cls, res): + test_res = cls() + test_res._initialize_response(res) + if not res.is_head: + await test_res._initialize_body(res) + test_res._process_text_body() + test_res._process_json_body() + test_res._process_sse_body() + return test_res + + +class TestClient: + """A test client for Microdot. + + :param app: The Microdot application instance. + :param cookies: A dictionary of cookies to use when sending requests to the + application. + :param scheme: The scheme to use for requests, either 'http' or 'https'. + :param host: The host to use for requests. + + The following example shows how to create a test client for an application + and send a test request:: + + from microdot import Microdot + + app = Microdot() + + @app.get('/') + async def index(): + return 'Hello, World!' + + async def test_hello_world(self): + client = TestClient(app) + res = await client.get('/') + assert res.status_code == 200 + assert res.text == 'Hello, World!' + """ + __test__ = False # remove this class from pytest's test collection + + def __init__(self, app, cookies=None, scheme=None, host=None): + self.app = app + self.cookies = cookies or {} + self.scheme = scheme + self.host = host or 'example.com:1234' + + def _process_body(self, body, headers): + if body is None: + body = b'' + elif isinstance(body, (dict, list)): + body = json.dumps(body) + if 'Content-Type' not in headers: # pragma: no cover + headers['Content-Type'] = 'application/json' + if isinstance(body, str): + body = body.encode() + if body and 'Content-Length' not in headers: + headers['Content-Length'] = str(len(body)) + return body, headers + + def _process_cookies(self, path, headers): + cookies = '' + for name, value in self.cookies.items(): + if isinstance(value, tuple): + value, cookie_path = value + if not path.startswith(cookie_path): + continue + if cookies: + cookies += '; ' + cookies += name + '=' + value + if cookies: + if 'Cookie' in headers: + headers['Cookie'] += '; ' + cookies + else: + headers['Cookie'] = cookies + return headers + + def _render_request(self, method, path, headers, body): + request_bytes = '{method} {path} HTTP/1.0\n'.format( + method=method, path=path) + if 'Host' not in headers: # pragma: no branch + request_bytes += 'Host: {host}\n'.format(host=self.host) + for header, value in headers.items(): + request_bytes += '{header}: {value}\n'.format( + header=header, value=value) + request_bytes = request_bytes.encode() + b'\n' + body + return request_bytes + + def _update_cookies(self, res): + cookies = res.headers.get('Set-Cookie', []) + for cookie in cookies: + cookie_name, cookie_value = cookie.split('=', 1) + cookie_options = cookie_value.split(';') + path = '/' + delete = False + for option in cookie_options[1:]: + option = option.strip().lower() + if option.startswith( + 'max-age='): # pragma: no cover + _, age = option.split('=', 1) + try: + age = int(age) + except ValueError: # pragma: no cover + age = 0 + if age <= 0: + delete = True + elif option.startswith('expires='): + _, e = option.split('=', 1) + # this is a very limited parser for cookie expiry + # that only detects a cookie deletion request when + # the date is 1/1/1970 + if '1 jan 1970' in e.lower(): # pragma: no branch + delete = True + elif option.startswith('path='): + _, path = option.split('=', 1) + if delete: + if cookie_name in self.cookies: # pragma: no branch + cookie_path = self.cookies[cookie_name][1] \ + if isinstance(self.cookies[cookie_name], tuple) \ + else '/' + if path == cookie_path: + del self.cookies[cookie_name] + else: + if path == '/': + self.cookies[cookie_name] = cookie_options[0] + else: + self.cookies[cookie_name] = (cookie_options[0], path) + + async def request(self, method, path, headers=None, body=None, sock=None): + headers = headers or {} + body, headers = self._process_body(body, headers) + headers = self._process_cookies(path, headers) + request_bytes = self._render_request(method, path, headers, body) + if sock: + reader = sock[0] + reader.buffer = request_bytes + writer = sock[1] + else: + reader = AsyncBytesIO(request_bytes) + writer = AsyncBytesIO(b'') + + req = await Request.create(self.app, reader, writer, + ('127.0.0.1', 1234), scheme=self.scheme) + res = await self.app.dispatch_request(req) + if res == Response.already_handled: + return TestResponse() + res.complete() + + self._update_cookies(res) + return await TestResponse.create(res) + + async def get(self, path, headers=None): + """Send a GET request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('GET', path, headers=headers) + + async def post(self, path, headers=None, body=None): + """Send a POST request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('POST', path, headers=headers, body=body) + + async def put(self, path, headers=None, body=None): + """Send a PUT request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('PUT', path, headers=headers, body=body) + + async def patch(self, path, headers=None, body=None): + """Send a PATCH request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('PATCH', path, headers=headers, body=body) + + async def delete(self, path, headers=None): + """Send a DELETE request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('DELETE', path, headers=headers) + + async def websocket(self, path, client, headers=None): + """Send a websocket connection request to the application. + + :param path: The request URL. + :param client: A generator function that yields client messages. + :param headers: A dictionary of headers to send with the request. + """ + gen = client() + + class FakeWebSocket: + def __init__(self): + self.started = False + self.closed = False + self.buffer = b'' + + async def _next(self, data=None): + try: + data = (await gen.asend(data)) if hasattr(gen, 'asend') \ + else gen.send(data) + except (StopIteration, StopAsyncIteration): + if not self.closed: + self.closed = True + raise OSError(32, 'Websocket connection closed') + return # pragma: no cover + opcode = WebSocket.TEXT if isinstance(data, str) \ + else WebSocket.BINARY + return WebSocket._encode_websocket_frame(opcode, data) + + async def read(self, n): + if not self.buffer: + self.started = True + self.buffer = await self._next() + data = self.buffer[:n] + self.buffer = self.buffer[n:] + return data + + async def readexactly(self, n): # pragma: no cover + return await self.read(n) + + async def readline(self): + line = b'' + while True: + line += await self.read(1) + if line[-1] in [b'\n', 10]: + break + return line + + async def awrite(self, data): + if self.started: + h = WebSocket._parse_frame_header(data[0:2]) + if h[1] not in [WebSocket.TEXT, WebSocket.BINARY]: + return + if h[3] < 0: + data = data[2 - h[3]:] + else: + data = data[2:] + if h[1] == WebSocket.TEXT: + data = data.decode() + self.buffer = await self._next(data) + + ws_headers = { + 'Upgrade': 'websocket', + 'Connection': 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + } + ws_headers.update(headers or {}) + sock = FakeWebSocket() + return await self.request('GET', path, headers=ws_headers, + sock=(sock, sock)) diff --git a/rinkhals-webui/src/microdot/utemplate.py b/rinkhals-webui/src/microdot/utemplate.py new file mode 100644 index 0000000..16d0398 --- /dev/null +++ b/rinkhals-webui/src/microdot/utemplate.py @@ -0,0 +1,70 @@ +from utemplate import recompile + +_loader = None + + +class Template: + """A template object. + + :param template: The filename of the template to render, relative to the + configured template directory. + """ + @classmethod + def initialize(cls, template_dir='templates', + loader_class=recompile.Loader): + """Initialize the templating subsystem. + + :param template_dir: the directory where templates are stored. This + argument is optional. The default is to load + templates from a *templates* subdirectory. + :param loader_class: the ``utemplate.Loader`` class to use when loading + templates. This argument is optional. The default + is the ``recompile.Loader`` class, which + automatically recompiles templates when they + change. + """ + global _loader + _loader = loader_class(None, template_dir) + + def __init__(self, template): + if _loader is None: # pragma: no cover + self.initialize() + #: The name of the template + self.name = template + self.template = _loader.load(template) + + def generate(self, *args, **kwargs): + """Return a generator that renders the template in chunks, with the + given arguments.""" + return self.template(*args, **kwargs) + + def render(self, *args, **kwargs): + """Render the template with the given arguments and return it as a + string.""" + return ''.join(self.generate(*args, **kwargs)) + + def generate_async(self, *args, **kwargs): + """Return an asynchronous generator that renders the template in + chunks, using the given arguments.""" + class sync_to_async_iter(): + def __init__(self, iter): + self.iter = iter + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration + + return sync_to_async_iter(self.generate(*args, **kwargs)) + + async def render_async(self, *args, **kwargs): + """Render the template with the given arguments asynchronously and + return it as a string.""" + response = '' + async for chunk in self.generate_async(*args, **kwargs): + response += chunk + return response diff --git a/rinkhals-webui/src/microdot/websocket.py b/rinkhals-webui/src/microdot/websocket.py new file mode 100644 index 0000000..63087e1 --- /dev/null +++ b/rinkhals-webui/src/microdot/websocket.py @@ -0,0 +1,231 @@ +import binascii +import hashlib +from microdot import Request, Response +from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception +from microdot.helpers import wraps + + +class WebSocketError(Exception): + """Exception raised when an error occurs in a WebSocket connection.""" + pass + + +class WebSocket: + """A WebSocket connection object. + + An instance of this class is sent to handler functions to manage the + WebSocket connection. + """ + CONT = 0 + TEXT = 1 + BINARY = 2 + CLOSE = 8 + PING = 9 + PONG = 10 + + #: Specify the maximum message size that can be received when calling the + #: ``receive()`` method. Messages with payloads that are larger than this + #: size will be rejected and the connection closed. Set to 0 to disable + #: the size check (be aware of potential security issues if you do this), + #: or to -1 to use the value set in + #: ``Request.max_body_length``. The default is -1. + #: + #: Example:: + #: + #: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages + max_message_length = -1 + + def __init__(self, request): + self.request = request + self.closed = False + + async def handshake(self): + response = self._handshake_response() + await self.request.sock[1].awrite( + b'HTTP/1.1 101 Switching Protocols\r\n') + await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') + await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') + await self.request.sock[1].awrite( + b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') + + async def receive(self): + """Receive a message from the client.""" + while True: + opcode, payload = await self._read_frame() + send_opcode, data = self._process_websocket_frame(opcode, payload) + if send_opcode: # pragma: no cover + await self.send(data, send_opcode) + elif data: # pragma: no branch + return data + + async def send(self, data, opcode=None): + """Send a message to the client. + + :param data: the data to send, given as a string or bytes. + :param opcode: a custom frame opcode to use. If not given, the opcode + is ``TEXT`` or ``BINARY`` depending on the type of the + data. + """ + frame = self._encode_websocket_frame( + opcode or (self.TEXT if isinstance(data, str) else self.BINARY), + data) + await self.request.sock[1].awrite(frame) + + async def close(self): + """Close the websocket connection.""" + if not self.closed: # pragma: no cover + self.closed = True + await self.send(b'', self.CLOSE) + + def _handshake_response(self): + connection = False + upgrade = False + websocket_key = None + for header, value in self.request.headers.items(): + h = header.lower() + if h == 'connection': + connection = True + if 'upgrade' not in value.lower(): + return self.request.app.abort(400) + elif h == 'upgrade': + upgrade = True + if not value.lower() == 'websocket': + return self.request.app.abort(400) + elif h == 'sec-websocket-key': + websocket_key = value + if not connection or not upgrade or not websocket_key: + return self.request.app.abort(400) + d = hashlib.sha1(websocket_key.encode()) + d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + return binascii.b2a_base64(d.digest())[:-1] + + @classmethod + def _parse_frame_header(cls, header): + fin = header[0] & 0x80 + opcode = header[0] & 0x0f + if fin == 0 or opcode == cls.CONT: # pragma: no cover + raise WebSocketError('Continuation frames not supported') + has_mask = header[1] & 0x80 + length = header[1] & 0x7f + if length == 126: + length = -2 + elif length == 127: + length = -8 + return fin, opcode, has_mask, length + + def _process_websocket_frame(self, opcode, payload): + if opcode == self.TEXT: + payload = payload.decode() + elif opcode == self.BINARY: + pass + elif opcode == self.CLOSE: + raise WebSocketError('Websocket connection closed') + elif opcode == self.PING: + return self.PONG, payload + elif opcode == self.PONG: # pragma: no branch + return None, None + return None, payload + + @classmethod + def _encode_websocket_frame(cls, opcode, payload): + frame = bytearray() + frame.append(0x80 | opcode) + if opcode == cls.TEXT: + payload = payload.encode() + if len(payload) < 126: + frame.append(len(payload)) + elif len(payload) < (1 << 16): + frame.append(126) + frame.extend(len(payload).to_bytes(2, 'big')) + else: + frame.append(127) + frame.extend(len(payload).to_bytes(8, 'big')) + frame.extend(payload) + return frame + + async def _read_frame(self): + header = await self.request.sock[0].read(2) + if len(header) != 2: # pragma: no cover + raise WebSocketError('Websocket connection closed') + fin, opcode, has_mask, length = self._parse_frame_header(header) + if length == -2: + length = await self.request.sock[0].readexactly(2) + length = int.from_bytes(length, 'big') + elif length == -8: + length = await self.request.sock[0].readexactly(8) + length = int.from_bytes(length, 'big') + max_allowed_length = Request.max_body_length \ + if self.max_message_length == -1 else self.max_message_length + if length > max_allowed_length: + raise WebSocketError('Message too large') + if has_mask: # pragma: no cover + mask = await self.request.sock[0].readexactly(4) + payload = await self.request.sock[0].readexactly(length) + if has_mask: # pragma: no cover + payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) + return opcode, payload + + +async def websocket_upgrade(request): + """Upgrade a request handler to a websocket connection. + + This function can be called directly inside a route function to process a + WebSocket upgrade handshake, for example after the user's credentials are + verified. The function returns the websocket object:: + + @app.route('/echo') + async def echo(request): + if not authenticate_user(request): + abort(401) + ws = await websocket_upgrade(request) + while True: + message = await ws.receive() + await ws.send(message) + """ + ws = WebSocket(request) + await ws.handshake() + + @request.after_request + async def after_request(request, response): + return Response.already_handled + + return ws + + +def websocket_wrapper(f, upgrade_function): + @wraps(f) + async def wrapper(request, *args, **kwargs): + ws = await upgrade_function(request) + try: + await f(request, ws, *args, **kwargs) + except OSError as exc: + if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover + raise + except WebSocketError: + pass + except Exception as exc: + print_exception(exc) + finally: # pragma: no cover + try: + await ws.close() + except Exception: + pass + return Response.already_handled + return wrapper + + +def with_websocket(f): + """Decorator to make a route a WebSocket endpoint. + + This decorator is used to define a route that accepts websocket + connections. The route then receives a websocket object as a second + argument that it can use to send and receive messages:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + """ + return websocket_wrapper(f, websocket_upgrade) diff --git a/rinkhals-webui/src/microdot/wsgi.py b/rinkhals-webui/src/microdot/wsgi.py new file mode 100644 index 0000000..67b9e1c --- /dev/null +++ b/rinkhals-webui/src/microdot/wsgi.py @@ -0,0 +1,161 @@ +import asyncio +import os +import signal +from microdot import * # noqa: F401, F403 +from microdot.microdot import Microdot as BaseMicrodot, Request, NoCaseDict, \ + MUTED_SOCKET_ERRORS +from microdot.websocket import WebSocket, websocket_upgrade, \ + with_websocket # noqa: F401 + + +class Microdot(BaseMicrodot): + """A subclass of the core :class:`Microdot ` class that + implements the WSGI protocol. + + This class must be used as the application instance when running under a + WSGI web server. + """ + def __init__(self): + super().__init__() + self.loop = asyncio.new_event_loop() + self.embedded_server = False + + def wsgi_app(self, environ, start_response): + """A WSGI application callable.""" + path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '') + if 'QUERY_STRING' in environ and environ['QUERY_STRING']: + path += '?' + environ['QUERY_STRING'] + headers = NoCaseDict() + content_length = 0 + for k, value in environ.items(): + if k.startswith('HTTP_'): + key = '-'.join([p.title() for p in k[5:].split('_')]) + headers[key] = value + elif k == 'CONTENT_TYPE': + headers['Content-Type'] = value + elif k == 'CONTENT_LENGTH': + headers['Content-Length'] = value + content_length = int(value) + + class sync_to_async_body_stream(): # pragma: no cover + def __init__(self, wsgi_input=None): + self.wsgi_input = wsgi_input + + async def read(self, n=-1): + return self.wsgi_input.read(n) + + async def readline(self): + return self.wsgi_input.readline() + + async def readexactly(self, n): + return self.wsgi_input.read(n) + + wsgi_input = environ.get('wsgi.input') + if content_length and content_length <= Request.max_body_length: + # the request came with a body that is within the allowed size + body = wsgi_input.read(content_length) + stream = None + sock = (None, None) + else: + body = b'' + if content_length: + # the request came with a body that is too large to fit in + # memory, so we stream it + stream = sync_to_async_body_stream(wsgi_input) + sock = (None, None) + else: + # the request did not declare a body size, so we connect the + # raw socket if available + stream = None + if 'gunicorn.socket' in environ: # pragma: no cover + reader, writer = self.loop.run_until_complete( + asyncio.open_connection( + sock=environ['gunicorn.socket'].dup())) + if not hasattr(writer, 'awrite'): # pragma: no cover + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + sock = (reader, writer) + else: + sock = (None, None) + + req = Request( + self, + (environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))), + environ['REQUEST_METHOD'], + path, + environ['SERVER_PROTOCOL'], + headers, + body=body, + stream=stream, + sock=sock, + scheme=environ.get('wsgi.url_scheme')) + req.environ = environ + + res = self.loop.run_until_complete(self.dispatch_request(req)) + res.complete() + if sock[1]: # pragma: no cover + try: + self.loop.run_until_complete(sock[1].aclose()) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + + reason = res.reason or ('OK' if res.status_code == 200 else 'N/A') + header_list = [] + for name, value in res.headers.items(): + if not isinstance(value, list): + header_list.append((name, value)) + else: + for v in value: + header_list.append((name, v)) + start_response(str(res.status_code) + ' ' + reason, header_list) + + class async_to_sync_iter(): + def __init__(self, iter, loop): + self.iter = iter.__aiter__() + self.loop = loop + + def __iter__(self): + return self + + def __next__(self): + try: + return self.loop.run_until_complete(self.iter.__anext__()) + except StopAsyncIteration: + raise StopIteration + + def close(self): # pragma: no cover + if hasattr(self.iter, 'aclose'): + self.loop.run_until_complete(self.iter.aclose()) + + return async_to_sync_iter(res.body_iter(), self.loop) + + def __call__(self, environ, start_response): + return self.wsgi_app(environ, start_response) + + def shutdown(self): + if self.embedded_server: # pragma: no cover + super().shutdown() + else: + pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid() + os.kill(pid, signal.SIGTERM) + + def run(self, host='0.0.0.0', port=5000, debug=False, + **options): # pragma: no cover + """Normally you would not start the server by invoking this method. + Instead, start your chosen WSGI web server and pass the ``Microdot`` + instance as the WSGI callable. + """ + self.embedded_server = True + super().run(host=host, port=port, debug=debug, **options) diff --git a/rinkhals-webui/src/rinkhals_webui/__init__.py b/rinkhals-webui/src/rinkhals_webui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rinkhals-webui/src/rinkhals_webui/__main__.py b/rinkhals-webui/src/rinkhals_webui/__main__.py new file mode 100644 index 0000000..40e2b01 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/rinkhals-webui/src/rinkhals_webui/ace.py b/rinkhals-webui/src/rinkhals_webui/ace.py new file mode 100644 index 0000000..6c6f6fb --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/ace.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, Dict, List, Optional + +from .klippy import KlippyClient, KlippyError + + +_CONFIG_KEYS = ( + "auto_refill", + "runout_detect", +) + +class ACEController: + """Controller for interacting with the ACE filament hub.""" + + def __init__(self, client: Optional[KlippyClient] = None) -> None: + self._client = client or KlippyClient() + + def list_hubs(self) -> List[Dict[str, Any]]: + response = self._client.call("objects/query", params={"objects": {"filament_hub": None}}) + try: + hubs = response["result"]["status"]["filament_hub"]["filament_hubs"] + except KeyError as e: + raise KlippyError("Klippy returned an incomplete ACE status payload") from e + + if not isinstance(hubs, Iterable): + raise KlippyError("Klippy returned an invalid ACE hub list") + + normalized: List[Dict[str, Any]] = [] + for hub in hubs: + if not isinstance(hub, Mapping): + continue + normalized.append(_normalize_hub(hub)) + return normalized + + def fetch_config(self) -> Dict[str, Any]: + response = self._client.call("filament_hub/get_config") + result = response.get("result") + if not isinstance(result, Mapping): + raise KlippyError("Klippy returned an invalid ACE config") + return {key: result.get(key) for key in _CONFIG_KEYS if key in result} + + def update_config(self, updates: Mapping[str, Any]) -> None: + if not updates: + raise ValueError("No configuration values supplied") + + payload = {key: updates[key] for key in _CONFIG_KEYS if key in updates} + if not payload: + raise ValueError("No supported configuration values supplied") + + self._client.call("filament_hub/set_config", params=payload) + + def set_filament(self, ace_id: int, slot: int, material: str, color_hex: str) -> None: + payload = { + "id": ace_id, + "index": slot, + "type": material, + "color": _parse_color_hex(color_hex), + } + self._client.call("filament_hub/set_filament_info", params=payload) + + def start_drying(self, ace_id: int, duration: int, temp: int, fan_speed: Optional[int]) -> None: + if duration <= 0: + raise ValueError("Duration must be greater than zero") + if temp <= 0: + raise ValueError("Temperature must be greater than zero") + if fan_speed is not None and fan_speed < 0: + raise ValueError("Fan speed must be non-negative") + + payload = { + "id": ace_id, + "duration": duration, + "temp": temp, + "fan_speed": 0 if fan_speed is None else max(0, fan_speed), + } + self._client.call("filament_hub/start_drying", params=payload) + + def stop_drying(self, ace_id: int) -> None: + self._client.call("filament_hub/stop_drying", params={"id": ace_id}) + + +def _normalize_hub(hub: Mapping[str, Any]) -> Dict[str, Any]: + slots = [] + for slot in hub.get("slots", []): + slots.append( + { + "index": slot.get("index"), + "status": slot.get("status"), + "type": slot.get("type"), + "color": _normalize_slot_color(slot.get("color")), + } + ) + + dryer_status = hub.get("dryer_status") or {} + dryer = { + "status": dryer_status.get("status"), + "target_temp": dryer_status.get("target_temp"), + "remaining_seconds": dryer_status.get("remain_time"), + "is_active": dryer_status.get("status") not in (None, "stop"), + } + + return { + "id": hub.get("id"), + "status": hub.get("status"), + "temperature": hub.get("temp"), + "slots": slots, + "dryer": dryer, + } + + +def _normalize_slot_color(color: Any) -> Optional[Dict[str, Any]]: + triplet = list(color) + if len(triplet) != 3: + return None + try: + r, g, b = (int(component) for component in triplet) + except (TypeError, ValueError): + return None + return {"rgb": {"r": r, "g": g, "b": b}, "hex": f"#{r:02X}{g:02X}{b:02X}"} + + +def _parse_color_hex(color_hex: str) -> Dict[str, int]: + value = color_hex.strip() + if value.startswith("#"): + value = value[1:] + if len(value) != 6: + raise ValueError("Color must use the #RRGGBB format") + + try: + r = int(value[0:2], 16) + g = int(value[2:4], 16) + b = int(value[4:6], 16) + except ValueError as e: + raise ValueError("Color must be a valid hex value") from e + return {"R": r, "G": g, "B": b} + diff --git a/rinkhals-webui/src/rinkhals_webui/apps.py b/rinkhals-webui/src/rinkhals_webui/apps.py new file mode 100644 index 0000000..eabee6e --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/apps.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +from typing import Any, Dict, List + +# Paths mirror the variables in tools.sh and get-entities-apps.sh +_RINKHALS_ROOT = "/useremain/rinkhals/.current" +_RINKHALS_HOME = "/useremain/rinkhals" +_BUILTIN_APPS_DIR = os.path.join(_RINKHALS_ROOT, "home", "rinkhals", "apps") +_USER_APPS_DIR = os.path.join(_RINKHALS_HOME, "apps") + +_SKIP_APPS: frozenset[str] = frozenset({"example"}) + + +class AppsController: + """Mirrors the logic of get-entities-apps.sh and toggle-app.sh.""" + + # ------------------------------------------------------------------ + # Discovery helpers + # ------------------------------------------------------------------ + + def _discover_apps(self) -> List[tuple[str, str, bool]]: + """Return a deduplicated list of (app_name, app_root, is_builtin) tuples.""" + seen: set[str] = set() + apps: List[tuple[str, str, bool]] = [] + + for base_dir, is_builtin in [ + (_BUILTIN_APPS_DIR, True), + (_USER_APPS_DIR, False), + ]: + if not os.path.isdir(base_dir): + continue + try: + entries = sorted(os.listdir(base_dir)) + except OSError: + continue + + for entry in entries: + if entry in _SKIP_APPS or entry.startswith("."): + continue + app_root = os.path.join(base_dir, entry) + if not os.path.isdir(app_root): + continue + if not ( + os.path.isfile(os.path.join(app_root, "app.json")) + and os.path.isfile(os.path.join(app_root, "app.sh")) + ): + continue + if entry not in seen: + seen.add(entry) + apps.append((entry, app_root, is_builtin)) + + return apps + + def _read_metadata(self, app_root: str) -> Dict[str, Any]: + path = os.path.join(app_root, "app.json") + try: + with open(path) as f: + raw = f.read() + # Strip JS-style single-line comments before JSON parsing + raw = re.sub(r"//[^\n]*", "", raw) + data = json.loads(raw) + # Validate schema version (matches get-entities-apps.sh guard) + if str(data.get("$version", "")) != "1": + return {} + return data + except Exception: + return {} + + def _is_enabled(self, app_name: str, app_root: str) -> bool: + """Matches the enabled-check from get-entities-apps.sh: + enabled = (.enabled in app_root OR $RINKHALS_HOME/apps/$app.enabled) AND + NOT (.disabled in app_root OR $RINKHALS_HOME/apps/$app.disabled) + """ + has_enabled = os.path.isfile(os.path.join(app_root, ".enabled")) or os.path.isfile( + os.path.join(_USER_APPS_DIR, f"{app_name}.enabled") + ) + has_disabled = os.path.isfile(os.path.join(app_root, ".disabled")) or os.path.isfile( + os.path.join(_USER_APPS_DIR, f"{app_name}.disabled") + ) + return has_enabled and not has_disabled + + def _get_status(self, app_root: str) -> str: + """Run app.sh status and extract the status word.""" + app_sh = os.path.join(app_root, "app.sh") + try: + result = subprocess.run( + [app_sh, "status"], + capture_output=True, + text=True, + timeout=5, + cwd=app_root, + ) + for line in result.stdout.splitlines(): + if "Status" in line: + parts = line.split() + return parts[1] if len(parts) >= 2 else "unknown" + except Exception: + pass + return "unknown" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def list_apps(self) -> List[Dict[str, Any]]: + result = [] + for app_name, app_root, _is_builtin in self._discover_apps(): + meta = self._read_metadata(app_root) + if not meta: + continue + result.append( + { + "app": app_name, + "name": meta.get("name", app_name), + "description": meta.get("description", ""), + "version": meta.get("version", ""), + "enabled": self._is_enabled(app_name, app_root), + "status": self._get_status(app_root), + } + ) + return result + + def toggle_app(self, app_name: str) -> bool: + """Toggle an app's enabled state; returns the new enabled state.""" + for name, app_root, is_builtin in self._discover_apps(): + if name == app_name: + return self._do_toggle(app_name, app_root, is_builtin) + raise ValueError(f"App not found: {app_name!r}") + + # ------------------------------------------------------------------ + # Toggle implementation (mirrors toggle-app.sh) + # ------------------------------------------------------------------ + + def _do_toggle(self, app_name: str, app_root: str, is_builtin: bool) -> bool: + currently_enabled = self._is_enabled(app_name, app_root) + os.makedirs(_USER_APPS_DIR, exist_ok=True) + + home_enabled = os.path.join(_USER_APPS_DIR, f"{app_name}.enabled") + home_disabled = os.path.join(_USER_APPS_DIR, f"{app_name}.disabled") + + if is_builtin: + # Builtin: use $RINKHALS_HOME/apps/$app.enabled / $app.disabled + if currently_enabled: + _rm(home_enabled) + _touch(home_disabled) + else: + _rm(home_disabled) + # Only create .enabled flag if the app root has no .enabled already + if not os.path.isfile(os.path.join(app_root, ".enabled")): + _touch(home_enabled) + else: + # User app: use $RINKHALS_HOME/apps/$app/.enabled / .disabled + app_enabled = os.path.join(_USER_APPS_DIR, app_name, ".enabled") + app_disabled = os.path.join(_USER_APPS_DIR, app_name, ".disabled") + _rm(home_enabled) + _rm(home_disabled) + if currently_enabled: + _rm(app_enabled) + _touch(app_disabled) + else: + _rm(app_disabled) + _touch(app_enabled) + + self._run_lifecycle(app_root, "stop" if currently_enabled else "start") + return not currently_enabled + + def _run_lifecycle(self, app_root: str, command: str) -> None: + app_sh = os.path.join(app_root, "app.sh") + if not os.path.isfile(app_sh): + return + try: + subprocess.run( + [app_sh, command], + capture_output=True, + timeout=10, + cwd=app_root, + ) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# File helpers +# --------------------------------------------------------------------------- + + +def _rm(path: str) -> None: + try: + os.remove(path) + except FileNotFoundError: + pass + + +def _touch(path: str) -> None: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + open(path, "a").close() diff --git a/rinkhals-webui/src/rinkhals_webui/klippy.py b/rinkhals-webui/src/rinkhals_webui/klippy.py new file mode 100644 index 0000000..deb8c66 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/klippy.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import os +import random +import socket +from typing import Any, Dict, Optional + + +_DEFAULT_SOCKET = "/tmp/unix_uds1" +_SOCKET_ENV = "KLIPPY_SOCKET_PATH" +_MESSAGE_TERMINATOR = "\x03" + + +class KlippyError(RuntimeError): + """Raised when Klippy connection fails or returns an error payload.""" + + +class KlippyClient: + """Client to interact with the Klippy API socket.""" + + def __init__(self, socket_path: Optional[str] = None, timeout: float = 3.0) -> None: + self.socket_path = socket_path or os.getenv(_SOCKET_ENV, _DEFAULT_SOCKET) + self.timeout = timeout + + def call(self, method: str, params: Optional[Dict[str, Any]] = None, request_id: Optional[int] = None) -> Dict[str, Any]: + """Send a JSON-RPC command and return the decoded response.""" + + payload = { + "method": method, + "params": params or {}, + "id": request_id or random.randint(1, 1_000_000), + } + response = self._send_request(payload) + if "error" in response: + raise KlippyError(response["error"]) + return response + + def _send_request(self, payload: Dict[str, Any]) -> Dict[str, Any]: + message = json.dumps(payload) + _MESSAGE_TERMINATOR + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + + try: + sock.connect(self.socket_path) + sock.sendall(message.encode("utf-8")) + + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + except OSError as e: + raise KlippyError(f"Unable to reach Klippy on {self.socket_path}: {e}") from e + finally: + try: + sock.close() + except OSError: + pass + + if not chunks: + raise KlippyError("Klippy returned an empty response") + + raw = b"".join(chunks).decode("utf-8").strip().rstrip(_MESSAGE_TERMINATOR) + try: + return json.loads(raw) + except ValueError as exc: + raise KlippyError(f"Klippy returned invalid JSON: {raw}") from exc diff --git a/rinkhals-webui/src/rinkhals_webui/main.py b/rinkhals-webui/src/rinkhals_webui/main.py new file mode 100644 index 0000000..12426b5 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/main.py @@ -0,0 +1,282 @@ +import glob +import json +import os +import subprocess + +from microdot import Microdot, Response + +from .ace import ACEController +from .apps import AppsController +from .klippy import KlippyError +from .printer import PrinterController +from .static import serve_static, frontend_missing_response + +app = Microdot() +Response.default_content_type = "text/html" + + +JSON_HEADERS = {"Content-Type": "application/json; charset=UTF-8"} +TEXT_HEADERS = {"Content-Type": "text/plain; charset=UTF-8"} + +ace_controller = ACEController() +apps_controller = AppsController() +printer_controller = PrinterController() + +_SCRIPTS_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "scripts")) +_LOG_DIR = "/useremain/rinkhals" + + +def json_response(payload, status_code=200): + return Response(json.dumps(payload), status_code=status_code, headers=JSON_HEADERS) + + +def error_response(message, status_code=500): + return json_response({"error": message}, status_code=status_code) + + +def get_json_body(request): + data = request.json + if data is None or not isinstance(data, dict): + raise ValueError("Expected JSON object body") + return data + + +@app.route("/api/health") +async def health(request): + return json_response({"status": "ok"}) + + +@app.route("/api/ace", methods=["GET"]) +async def ace_status(request): + try: + hubs = ace_controller.list_hubs() + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"filament_hubs": hubs}) + + +@app.route("/api/ace/config", methods=["GET"]) +async def ace_config(request): + try: + config = ace_controller.fetch_config() + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response(config) + + +@app.route("/api/ace/config", methods=["POST"]) +async def update_ace_config(request): + try: + body = get_json_body(request) + ace_controller.update_config(body) + except ValueError as exc: + return error_response(str(exc), status_code=400) + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"status": "ok"}) + + +@app.route("/api/ace//filament", methods=["POST"]) +async def set_filament(request, ace_id): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + + missing = [field for field in ("slot", "material", "color") if field not in body] + if missing: + return error_response(f"Missing fields: {', '.join(missing)}", status_code=400) + + try: + slot = int(body["slot"]) + material = str(body["material"]) + color = str(body["color"]) + ace_controller.set_filament(ace_id, slot, material, color) + except (ValueError, KlippyError) as exc: + return error_response(str(exc), status_code=400 if isinstance(exc, ValueError) else 502) + + return json_response({"status": "ok"}) + + +@app.route("/api/ace//drying", methods=["POST"]) +async def start_drying(request, ace_id): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + + missing = [field for field in ("duration", "temp") if field not in body] + if missing: + return error_response(f"Missing fields: {', '.join(missing)}", status_code=400) + + try: + duration = int(body["duration"]) + temp = int(body["temp"]) + fan_speed = body.get("fan_speed") + ace_controller.start_drying(ace_id, duration, temp, None if fan_speed is None else int(fan_speed)) + except (ValueError, KlippyError) as exc: + return error_response(str(exc), status_code=400 if isinstance(exc, ValueError) else 502) + + return json_response({"status": "ok"}) + + +@app.route("/api/ace//drying", methods=["DELETE"]) +async def stop_drying(request, ace_id): + try: + ace_controller.stop_drying(ace_id) + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# LED API +# --------------------------------------------------------------------------- + + +@app.route("/api/led", methods=["POST"]) +async def set_led(request): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + if "on" not in body: + return error_response("Missing field: on", status_code=400) + try: + printer_controller.set_led(bool(body["on"])) + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"on": bool(body["on"])}) + + +@app.route("/api/camera-led", methods=["GET"]) +async def get_camera_led(request): + try: + on = printer_controller.get_camera_led() + except Exception as exc: + return error_response(str(exc)) + return json_response({"on": on}) + + +@app.route("/api/camera-led", methods=["POST"]) +async def set_camera_led(request): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + if "on" not in body: + return error_response("Missing field: on", status_code=400) + try: + printer_controller.set_camera_led(bool(body["on"])) + except Exception as exc: + return error_response(str(exc)) + return json_response({"on": bool(body["on"])}) + + +# --------------------------------------------------------------------------- +# Apps API +# --------------------------------------------------------------------------- + + +@app.route("/api/apps", methods=["GET"]) +async def list_apps(request): + try: + apps = apps_controller.list_apps() + except Exception as exc: + return error_response(str(exc)) + return json_response({"apps": apps}) + + +@app.route("/api/apps//toggle", methods=["POST"]) +async def toggle_app(request, name): + try: + enabled = apps_controller.toggle_app(name) + except ValueError as exc: + return error_response(str(exc), status_code=404) + except Exception as exc: + return error_response(str(exc)) + return json_response({"app": name, "enabled": enabled}) + + +# --------------------------------------------------------------------------- +# System API +# --------------------------------------------------------------------------- + + +@app.route("/api/system/sync", methods=["POST"]) +async def system_sync(request): + try: + subprocess.run(["sync"], timeout=10, check=True) + except Exception as exc: + return error_response(str(exc)) + return json_response({"status": "ok"}) + + +@app.route("/api/system/reboot", methods=["POST"]) +async def system_reboot(request): + # Popen is non-blocking; the response is delivered before the OS reboots. + subprocess.Popen(["reboot"]) + return json_response({"status": "rebooting"}) + + +@app.route("/api/system/logs", methods=["GET"]) +async def system_logs(request): + parts = [] + for path in sorted(glob.glob(os.path.join(_LOG_DIR, "*.log"))): + try: + with open(path) as f: + content = f.read() + parts.append(f"=== {path} ===\n{content}") + except OSError: + pass + body = "\n\n".join(parts) if parts else "(no log files found)" + return Response(body, status_code=200, headers=TEXT_HEADERS) + + +@app.route("/api/system/update", methods=["GET"]) +async def system_update(request): + script = os.path.join(_SCRIPTS_DIR, "ota-update-info.py") + try: + result = subprocess.run( + ["python3", script], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Script exited with non-zero status") + data = json.loads(result.stdout) + return json_response(data) + except Exception as exc: + return json_response({"status": "error", "message": str(exc)}) + + +# --------------------------------------------------------------------------- +# Static / index +# --------------------------------------------------------------------------- + + +@app.route("/") +async def index(request): + index = serve_static("index.html") + if index is None: + return frontend_missing_response() + return index + + +@app.route("/") +async def static(request, path): + asset = serve_static(path) + if asset is not None: + return asset + return Response("Not Found", status_code=404, headers=TEXT_HEADERS) + + +def main() -> None: + """Start the Microdot development server.""" + + app.run(host="0.0.0.0", port=1414, debug=True) + + +if __name__ == "__main__": + main() diff --git a/rinkhals-webui/src/rinkhals_webui/printer.py b/rinkhals-webui/src/rinkhals_webui/printer.py new file mode 100644 index 0000000..b8d85a7 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/printer.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import fcntl +import struct +from typing import Optional + +from .klippy import KlippyClient + +_VIDEO_DEVICE = "/dev/video10" +_VIDIOC_G_CTRL = 0xC008561B +_VIDIOC_S_CTRL = 0xC008561C +_V4L2_CID_GAIN = 0x00980913 + + +class PrinterController: + """Controller for printer-level commands.""" + + def __init__(self, client: Optional[KlippyClient] = None) -> None: + self._client = client or KlippyClient() + + def set_led(self, on: bool) -> None: + """Turn the print head LED on (True) or off (False).""" + self._client.call("led/set_led", params={"S": 1 if on else 0}) + + def get_camera_led(self) -> bool: + """Read the camera LED state directly from the V4L2 device.""" + buf = struct.pack("Ii", _V4L2_CID_GAIN, 0) + with open(_VIDEO_DEVICE, "rb") as fd: + result = fcntl.ioctl(fd, _VIDIOC_G_CTRL, buf) + _, value = struct.unpack("Ii", result) + return value != 0 + + def set_camera_led(self, on: bool) -> None: + """Set the camera LED state directly via the V4L2 device.""" + buf = struct.pack("Ii", _V4L2_CID_GAIN, 1 if on else 0) + with open(_VIDEO_DEVICE, "rb") as fd: + fcntl.ioctl(fd, _VIDIOC_S_CTRL, buf) diff --git a/rinkhals-webui/src/rinkhals_webui/static.py b/rinkhals-webui/src/rinkhals_webui/static.py new file mode 100644 index 0000000..2490a17 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/static.py @@ -0,0 +1,38 @@ +import os +from os import path + +from microdot import Response, send_file + + +STATIC_DIR = os.environ.get("STATIC_DIR") + + +def frontend_missing_response() -> Response: + """Return a helpful message when the static bundle is unavailable.""" + + message = ( + "Static frontend bundle not found. Run `hatch run build-frontend` from the project root." + ) + + return Response(message, status_code=503, headers={"Content-Type": "text/plain; charset=UTF-8"}) + + +def serve_static(asset_path: str): + """Return a file under the static directory if it exists.""" + + if not STATIC_DIR or not path.isdir(STATIC_DIR): + return None + + relative_path = asset_path.lstrip("/") + candidate = path.normpath(path.join(STATIC_DIR, relative_path)) + dist_prefix = STATIC_DIR if STATIC_DIR.endswith("/") else STATIC_DIR + "/" + + # Ensure requested file stays within dist_root (prefix match on normalized + # paths works because both are absolute and canonicalized). + if candidate != STATIC_DIR and not candidate.startswith(dist_prefix): + return None + + if path.isfile(candidate): + return send_file(candidate) + + return None