diff --git a/.gitignore b/.gitignore index ffe490a..4c70900 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +nohup.out + # Python __pycache__/ *.py[cod] @@ -11,7 +13,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..9e31cf6 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,11 @@ +{ + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +} + +doodlebot.media.mit.edu { + reverse_proxy backend:80 +} + +http://doodlebot.media.mit.edu { + reverse_proxy backend:80 +} \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..1f4b513 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,36 @@ +# Deployment + +The doodlebot application is currently deployed on an Ubuntu virtual machine issued to us by Necsys: [doodlebot.media.mit.edu](https://doodlebot.media.mit.edu) + +It works by executing a series of [docker containers](https://www.docker.com/resources/what-container/): + +- **backend:** This container executes the FastAPI application defined in [main.py](./main.py). The FastAPI is also responsible for serving the static assets of the **frontend** and **playground** containers described below. +- **frontend:** This container builds the [SvelteKit](https://svelte.dev/) web app defined in [frontend/](./frontend/) into a static website, which acts as the frontend you see when navigating to [doodlebot.media.mit.edu](https://doodlebot.media.mit.edu) +- **playground:** This container pulls down a single deployed folder of the [RAISE Playground repo](https://github.com/mitmedialab/prg-raise-playground) + - Currently, it pulls down the deploy of the [curriculum branch](https://github.com/mitmedialab/prg-raise-playground/tree/curriculum), which is configured in [docker/playground.Dockerfile](./docker/playground.Dockerfile) +- **caddy:** This container executes the [Caddy](https://caddyserver.com/) web server, which routes traffic to the **backend** container + +The activity of these containers is coordinated using [docker compose](https://docs.docker.com/compose/) as configured in [docker/compose.yml](./docker/compose.yml). + + +## Deployment Steps + +The following steps demonstrate how to re-deploy the backend after pushing up changes from your local development environment (you should **NOT** develop directly on this machine). + +The steps make reference to shell scripts stored directly on the deployment machine (and not in this repo, though they make use of scripts stored inside of the [cli/](./cli/) directory). These exist just to make the deployment process easier, but they often are pretty simple and you should definitely check out their contents (e.g. `cat ./example.sh`) to get a clear picture of what's going on + +1. Log onto the VM: `ssh @doodlebot.media.mit.edu` + - Use the `user_id` @pmalacho provided to you as well as the password when prompted +2. Create a sudo session: `sudo su` + - You'll again be prompted for your password +3. Change directory to home: `cd` +3. Pull the latest changes for this repo: `./pull.sh` +4. (Re)start the docker containers: `./start.sh` + - **NOTE:** If you deem it necessary, you can explicitly stop all currently running containers first: `./stop.sh` +5. When you run the above `./start.sh` command, you should see `nohup: ignoring input and appending output to 'nohup.out'` + - This message indicates that we are running a process (specifically [cli/prod.sh](./cli/prod.sh)) through the [nohup](https://www.digitalocean.com/community/tutorials/nohup-command-in-linux) utility. It stands for "No Hang Up", which is useful for us so that the deployment process continues even when we exit the terminal (aka "hang up"). + - This command won't exit until all of the docker containers are built. If you need to see the output of this build process, you can open another terminal, ssh onto the machine, start a sudo session (`sudo su`) and run `./log-build.sh` (which literally just prints the contents of the `'nohup.out'` file referenced above). + - As you can see, it's sometimes helpful to have two terminals connected to the deployment machine, especially when debugging. +6. Once the `./start.sh` command exits, the server will be starting up. During this time the site ([doodlebot.media.mit.edu](https://doodlebot.media.mit.edu)) will not be reachable -- in this sense, we do **NOT** currently support [zero downtime deployments](https://www.pingidentity.com/en/resources/blog/post/what-is-zero-downtime-deployment.html). + - To see the output of the server at runtime, run `./log-runtime.sh`. + - Once you see `doodlebot-backend | Mounted frontend at /` in the output of `./log-runtime.sh`, the full site should be responsive. \ No newline at end of file diff --git a/cli/prod.sh b/cli/prod.sh new file mode 100755 index 0000000..f906673 --- /dev/null +++ b/cli/prod.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status. +set -e + +# Get the directory of the Docker Compose file +ROOT_DIR="$(git rev-parse --show-toplevel)" +DOCKER_DIR="$ROOT_DIR/docker" +COMPOSE_FILE="$DOCKER_DIR/compose.yml" + +docker compose -f "$COMPOSE_FILE" up --build frontend playground + +docker compose -f "$COMPOSE_FILE" up --build --detach backend caddy \ No newline at end of file diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..edfa78c --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,2 @@ +.frontend +.playground \ No newline at end of file diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 0000000..5f8c4c7 --- /dev/null +++ b/docker/backend.Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.9 + +# Install dependencies for pyaudio +RUN apt-get update +RUN apt-get install libasound-dev libportaudio2 libportaudiocpp0 portaudio19-dev -y +RUN apt-get install gcc -y + +WORKDIR /app + +COPY ./requirements.txt /app/requirements.txt + +RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt + +COPY . /app + +CMD ["uvicorn", "main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "80"] \ No newline at end of file diff --git a/docker/backend.Dockerfile.dockerignore b/docker/backend.Dockerfile.dockerignore new file mode 100644 index 0000000..a283ba9 --- /dev/null +++ b/docker/backend.Dockerfile.dockerignore @@ -0,0 +1,101 @@ +cli +docker +frontend + +# Tests +**/__tests__/ +conftest.py + +# Dev / database migrations +**/alembic* + +# Git +.git +.gitignore +.gitattributes + + +# CI +.codeclimate.yml +.travis.yml +.taskcluster.yml + +# Docker +docker-compose.yml +Dockerfile +*Dockerfile* +.docker +.dockerignore + +# Byte-compiled / optimized / DLL files +**/__pycache__/ +**/*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Virtual environment +.env +.venv/ +venv/ + +# PyCharm +.idea + +# Python mode for VIM +.ropeproject +**/.ropeproject + +# Vim swap files +**/*.swp + +# VS Code +.vscode/ \ No newline at end of file diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..cb3d9b6 --- /dev/null +++ b/docker/compose.yml @@ -0,0 +1,54 @@ +services: + frontend: + container_name: doodlebot-frontend + volumes: + - ./.frontend:/dist + build: + context: ../frontend + dockerfile: ../docker/frontend.Dockerfile # NOTE: path is relative to above context + restart: "no" + + playground: + container_name: doodlebot-playground + volumes: + - ./.playground:/dist + build: + dockerfile: ./playground.Dockerfile + restart: "no" + + backend: + container_name: doodlebot-backend + build: + context: .. + dockerfile: ./docker/backend.Dockerfile + ports: + - "8000:80" + volumes: + - "./.frontend:/app/frontend" + - "./.playground:/app/frontend/playground" + env_file: + - path: ../.env + required: false + networks: + - app-network + + caddy: + container_name: doodlebot-caddy + image: caddy:latest + ports: + - "80:80" + - "443:443" + volumes: + - ../Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + caddy_data: + caddy_config: \ No newline at end of file diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile new file mode 100644 index 0000000..1383446 --- /dev/null +++ b/docker/frontend.Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-slim AS dependencies +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && npm install -g corepack@latest +COPY package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +FROM dependencies AS build +COPY . /app +WORKDIR /app +RUN pnpm build +CMD cp -r build/* /dist diff --git a/docker/frontend.Dockerfile.dockerignore b/docker/frontend.Dockerfile.dockerignore new file mode 100644 index 0000000..fe6a775 --- /dev/null +++ b/docker/frontend.Dockerfile.dockerignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* \ No newline at end of file diff --git a/docker/playground.Dockerfile b/docker/playground.Dockerfile new file mode 100644 index 0000000..a98bb7a --- /dev/null +++ b/docker/playground.Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:latest AS base +RUN apt-get update && apt-get install -y git + +WORKDIR /playground + +# Clone the repo shallowly, filtering out all blobs initially, and only fetching the gh-pages branch. +# "--filter=blob:none" means blobs (file contents) are only downloaded on demand. +# "--sparse" enables sparse checkout directly from clone. +RUN git clone --depth=1 --branch=gh-pages --filter=blob:none --sparse \ + https://github.com/mitmedialab/prg-raise-playground.git /playground + +# Enable sparse checkout and select only the "curriculum" directory. +RUN git sparse-checkout set curriculum + +# After setting sparse-checkout, "git checkout" will update the working copy to include only that folder. +RUN git checkout + +CMD git pull && cp -r curriculum/* /dist \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..b5b2950 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..08f3176 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/events": "^3.0.3", + "@types/web-bluetooth": "^0.0.20", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "events": "^3.3.0", + "microbit-web-bluetooth": "^0.7.0" + } +} \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..662ede4 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,985 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@sveltejs/adapter-static': + specifier: ^3.0.6 + version: 3.0.6(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9))) + events: + specifier: ^3.3.0 + version: 3.3.0 + microbit-web-bluetooth: + specifier: ^0.7.0 + version: 0.7.0 + devDependencies: + '@sveltejs/kit': + specifier: ^2.9.0 + version: 2.9.0(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)) + '@types/events': + specifier: ^3.0.3 + version: 3.0.3 + '@types/web-bluetooth': + specifier: ^0.0.20 + version: 0.0.20 + svelte: + specifier: ^5.0.0 + version: 5.9.0 + svelte-check: + specifier: ^4.0.0 + version: 4.1.1(svelte@5.9.0)(typescript@5.7.2) + typescript: + specifier: ^5.0.0 + version: 5.7.2 + vite: + specifier: ^6.0.0 + version: 6.0.3(@types/node@20.17.9) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@esbuild/aix-ppc64@0.24.0': + resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.24.0': + resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.24.0': + resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.24.0': + resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.24.0': + resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.24.0': + resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.24.0': + resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.24.0': + resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.24.0': + resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.24.0': + resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.24.0': + resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.24.0': + resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.24.0': + resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.24.0': + resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.24.0': + resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.24.0': + resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.24.0': + resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.24.0': + resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.24.0': + resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.24.0': + resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.24.0': + resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.24.0': + resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.24.0': + resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.24.0': + resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@polka/url@1.0.0-next.28': + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + + '@rollup/rollup-android-arm-eabi@4.28.1': + resolution: {integrity: sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.28.1': + resolution: {integrity: sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.28.1': + resolution: {integrity: sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.28.1': + resolution: {integrity: sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.28.1': + resolution: {integrity: sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.28.1': + resolution: {integrity: sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.28.1': + resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.28.1': + resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.28.1': + resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.28.1': + resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.28.1': + resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.28.1': + resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.28.1': + resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.28.1': + resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.28.1': + resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.28.1': + resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.28.1': + resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.28.1': + resolution: {integrity: sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.28.1': + resolution: {integrity: sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==} + cpu: [x64] + os: [win32] + + '@sveltejs/adapter-static@3.0.6': + resolution: {integrity: sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.9.0': + resolution: {integrity: sha512-W3E7ed3ChB6kPqRs2H7tcHp+Z7oiTFC6m+lLyAQQuyXeqw6LdNuuwEUla+5VM0OGgqQD+cYD6+7Xq80vVm17Vg==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 || ^6.0.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.0.1': + resolution: {integrity: sha512-D5l5+STmywGoLST07T9mrqqFFU+xgv5fqyTWM+VbxTvQ6jujNn4h3lQNCvlwVYs4Erov8i0K5Rwr3LQtmBYmBw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/events@3.0.3': + resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} + + '@types/node@20.17.9': + resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + acorn-typescript@1.4.13: + resolution: {integrity: sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==} + peerDependencies: + acorn: '>=8.9.0' + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.1.1: + resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + + esbuild@0.24.0: + resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.1: + resolution: {integrity: sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==} + + esrap@1.2.3: + resolution: {integrity: sha512-ZlQmCCK+n7SGoqo7DnfKaP1sJZa49P01/dXzmjCASSo04p72w8EksT2NMK8CEX8DhKsfJXANioIw8VyHNsBfvQ==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + fdir@6.4.2: + resolution: {integrity: sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.14: + resolution: {integrity: sha512-5c99P1WKTed11ZC0HMJOj6CDIue6F8ySu+bJL+85q1zBEIY8IklrJ1eiKC2NDRh3Ct3FcvmJPyQHb9erXMTJNw==} + + microbit-web-bluetooth@0.7.0: + resolution: {integrity: sha512-CvJnQZJb/QOQfpNHiMRrLP+Djtodn5kmHKMn0OhbwYWXpFwcSIOzbyK5Jfdv8xnqFG7n4YUtOJtR0WdE3ql6cQ==} + engines: {node: '>=10.16.0'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.8: + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.0.2: + resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} + engines: {node: '>= 14.16.0'} + + rollup@4.28.1: + resolution: {integrity: sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + + sirv@3.0.0: + resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + svelte-check@4.1.1: + resolution: {integrity: sha512-NfaX+6Qtc8W/CyVGS/F7/XdiSSyXz+WGYA9ZWV3z8tso14V2vzjfXviKaTFEzB7g8TqfgO2FOzP6XT4ApSTUTw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.9.0: + resolution: {integrity: sha512-ZcC3BtjIDa4yfhAyAr94MxDQLD97zbpXmaUldFv2F5AkdZwYgQYB3BZVNRU5zEVaeeHoAns8ADiRMnre3QmpxQ==} + engines: {node: '>=18'} + + tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + vite@6.0.3: + resolution: {integrity: sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.0.4: + resolution: {integrity: sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.2: + resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@esbuild/aix-ppc64@0.24.0': + optional: true + + '@esbuild/android-arm64@0.24.0': + optional: true + + '@esbuild/android-arm@0.24.0': + optional: true + + '@esbuild/android-x64@0.24.0': + optional: true + + '@esbuild/darwin-arm64@0.24.0': + optional: true + + '@esbuild/darwin-x64@0.24.0': + optional: true + + '@esbuild/freebsd-arm64@0.24.0': + optional: true + + '@esbuild/freebsd-x64@0.24.0': + optional: true + + '@esbuild/linux-arm64@0.24.0': + optional: true + + '@esbuild/linux-arm@0.24.0': + optional: true + + '@esbuild/linux-ia32@0.24.0': + optional: true + + '@esbuild/linux-loong64@0.24.0': + optional: true + + '@esbuild/linux-mips64el@0.24.0': + optional: true + + '@esbuild/linux-ppc64@0.24.0': + optional: true + + '@esbuild/linux-riscv64@0.24.0': + optional: true + + '@esbuild/linux-s390x@0.24.0': + optional: true + + '@esbuild/linux-x64@0.24.0': + optional: true + + '@esbuild/netbsd-x64@0.24.0': + optional: true + + '@esbuild/openbsd-arm64@0.24.0': + optional: true + + '@esbuild/openbsd-x64@0.24.0': + optional: true + + '@esbuild/sunos-x64@0.24.0': + optional: true + + '@esbuild/win32-arm64@0.24.0': + optional: true + + '@esbuild/win32-ia32@0.24.0': + optional: true + + '@esbuild/win32-x64@0.24.0': + optional: true + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@polka/url@1.0.0-next.28': {} + + '@rollup/rollup-android-arm-eabi@4.28.1': + optional: true + + '@rollup/rollup-android-arm64@4.28.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.28.1': + optional: true + + '@rollup/rollup-darwin-x64@4.28.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.28.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.28.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.28.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.28.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.28.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.28.1': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.28.1': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.28.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.28.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.28.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.28.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.28.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.28.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.28.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.28.1': + optional: true + + '@sveltejs/adapter-static@3.0.6(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))': + dependencies: + '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)) + + '@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 5.1.1 + esm-env: 1.2.1 + import-meta-resolve: 4.1.0 + kleur: 4.1.5 + magic-string: 0.30.14 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.7.1 + sirv: 3.0.0 + svelte: 5.9.0 + tiny-glob: 0.2.9 + vite: 6.0.3(@types/node@20.17.9) + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)) + debug: 4.4.0 + svelte: 5.9.0 + vite: 6.0.3(@types/node@20.17.9) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.1(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)))(svelte@5.9.0)(vite@6.0.3(@types/node@20.17.9)) + debug: 4.4.0 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.14 + svelte: 5.9.0 + vite: 6.0.3(@types/node@20.17.9) + vitefu: 1.0.4(vite@6.0.3(@types/node@20.17.9)) + transitivePeerDependencies: + - supports-color + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.6': {} + + '@types/events@3.0.3': {} + + '@types/node@20.17.9': + dependencies: + undici-types: 6.19.8 + + '@types/web-bluetooth@0.0.20': {} + + acorn-typescript@1.4.13(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + + acorn@8.14.0: {} + + aria-query@5.3.2: {} + + axobject-query@4.1.0: {} + + chokidar@4.0.1: + dependencies: + readdirp: 4.0.2 + + cookie@0.6.0: {} + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + devalue@5.1.1: {} + + esbuild@0.24.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.0 + '@esbuild/android-arm': 0.24.0 + '@esbuild/android-arm64': 0.24.0 + '@esbuild/android-x64': 0.24.0 + '@esbuild/darwin-arm64': 0.24.0 + '@esbuild/darwin-x64': 0.24.0 + '@esbuild/freebsd-arm64': 0.24.0 + '@esbuild/freebsd-x64': 0.24.0 + '@esbuild/linux-arm': 0.24.0 + '@esbuild/linux-arm64': 0.24.0 + '@esbuild/linux-ia32': 0.24.0 + '@esbuild/linux-loong64': 0.24.0 + '@esbuild/linux-mips64el': 0.24.0 + '@esbuild/linux-ppc64': 0.24.0 + '@esbuild/linux-riscv64': 0.24.0 + '@esbuild/linux-s390x': 0.24.0 + '@esbuild/linux-x64': 0.24.0 + '@esbuild/netbsd-x64': 0.24.0 + '@esbuild/openbsd-arm64': 0.24.0 + '@esbuild/openbsd-x64': 0.24.0 + '@esbuild/sunos-x64': 0.24.0 + '@esbuild/win32-arm64': 0.24.0 + '@esbuild/win32-ia32': 0.24.0 + '@esbuild/win32-x64': 0.24.0 + + esm-env@1.2.1: {} + + esrap@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@types/estree': 1.0.6 + + events@3.3.0: {} + + fdir@6.4.2: {} + + fsevents@2.3.3: + optional: true + + globalyzer@0.1.0: {} + + globrex@0.1.2: {} + + import-meta-resolve@4.1.0: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.6 + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + magic-string@0.30.14: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + microbit-web-bluetooth@0.7.0: + dependencies: + '@types/node': 20.17.9 + '@types/web-bluetooth': 0.0.20 + + mri@1.2.0: {} + + mrmime@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.8: {} + + picocolors@1.1.1: {} + + postcss@8.4.49: + dependencies: + nanoid: 3.3.8 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.0.2: {} + + rollup@4.28.1: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.28.1 + '@rollup/rollup-android-arm64': 4.28.1 + '@rollup/rollup-darwin-arm64': 4.28.1 + '@rollup/rollup-darwin-x64': 4.28.1 + '@rollup/rollup-freebsd-arm64': 4.28.1 + '@rollup/rollup-freebsd-x64': 4.28.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.28.1 + '@rollup/rollup-linux-arm-musleabihf': 4.28.1 + '@rollup/rollup-linux-arm64-gnu': 4.28.1 + '@rollup/rollup-linux-arm64-musl': 4.28.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.28.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.28.1 + '@rollup/rollup-linux-riscv64-gnu': 4.28.1 + '@rollup/rollup-linux-s390x-gnu': 4.28.1 + '@rollup/rollup-linux-x64-gnu': 4.28.1 + '@rollup/rollup-linux-x64-musl': 4.28.1 + '@rollup/rollup-win32-arm64-msvc': 4.28.1 + '@rollup/rollup-win32-ia32-msvc': 4.28.1 + '@rollup/rollup-win32-x64-msvc': 4.28.1 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + set-cookie-parser@2.7.1: {} + + sirv@3.0.0: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + svelte-check@4.1.1(svelte@5.9.0)(typescript@5.7.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + chokidar: 4.0.1 + fdir: 6.4.2 + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.9.0 + typescript: 5.7.2 + transitivePeerDependencies: + - picomatch + + svelte@5.9.0: + dependencies: + '@ampproject/remapping': 2.3.0 + '@jridgewell/sourcemap-codec': 1.5.0 + '@types/estree': 1.0.6 + acorn: 8.14.0 + acorn-typescript: 1.4.13(acorn@8.14.0) + aria-query: 5.3.2 + axobject-query: 4.1.0 + esm-env: 1.2.1 + esrap: 1.2.3 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.14 + zimmerframe: 1.1.2 + + tiny-glob@0.2.9: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + + totalist@3.0.1: {} + + typescript@5.7.2: {} + + undici-types@6.19.8: {} + + vite@6.0.3(@types/node@20.17.9): + dependencies: + esbuild: 0.24.0 + postcss: 8.4.49 + rollup: 4.28.1 + optionalDependencies: + '@types/node': 20.17.9 + fsevents: 2.3.3 + + vitefu@1.0.4(vite@6.0.3(@types/node@20.17.9)): + optionalDependencies: + vite: 6.0.3(@types/node@20.17.9) + + zimmerframe@1.1.2: {} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..f8b2ff3 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + %sveltekit.head% + + + +
%sveltekit.body%
+ + + \ No newline at end of file diff --git a/frontend/src/lib/communication/EventDispatcher.ts b/frontend/src/lib/communication/EventDispatcher.ts new file mode 100644 index 0000000..92a7b8e --- /dev/null +++ b/frontend/src/lib/communication/EventDispatcher.ts @@ -0,0 +1,57 @@ +/* + * micro:bit Web Bluetooth + * Copyright (c) 2019 Rob Moran + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { EventEmitter } from "events"; + +export default class EventDispatcher = Record> extends EventEmitter { + isEventListenerObject(listener: any): listener is EventListenerObject { + return listener.handleEvent !== undefined; + } + + addEventListener(type: K, listener: (event: CustomEvent) => void): void { + if (listener) { + const handler = this.isEventListenerObject(listener) + ? listener.handleEvent + : listener; + super.addListener(type, handler); + } + } + + removeEventListener(type: K, callback: (event: CustomEvent) => void): void { + if (callback) { + const handler = this.isEventListenerObject(callback) + ? callback.handleEvent + : callback; + super.removeListener(type, handler); + } + } + + dispatchEvent>(eventOrType: K, detail: K extends string ? T[K] : K): boolean { + const event = typeof eventOrType === "string" + ? new CustomEvent(eventOrType, { detail, }) + : eventOrType as CustomEvent; + return super.emit(event.type, event); + } +} \ No newline at end of file diff --git a/frontend/src/lib/communication/PromiseQueue.ts b/frontend/src/lib/communication/PromiseQueue.ts new file mode 100644 index 0000000..fb2b3e2 --- /dev/null +++ b/frontend/src/lib/communication/PromiseQueue.ts @@ -0,0 +1,70 @@ +/* + * micro:bit Web Bluetooth + * Copyright (c) 2019 Rob Moran + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +type QueuedPromise = { + fn: () => Promise; + resolve: (value?: any | PromiseLike | undefined) => void; + reject: (reason?: any) => void; +} + +export default class PromiseQueue { + private queue: QueuedPromise[] = []; + private running: number = 0; + + constructor(private concurrent = 1) { } + + async pump(): Promise { + if (this.running >= this.concurrent) return; + + const promise = this.queue.shift(); + + if (!promise) return; + + this.running++; + + try { + const result = await promise.fn(); + promise.resolve(result); + } catch (error) { + promise.reject(error); + } + + this.running--; + + return this.pump(); + } + + add(fn: QueuedPromise["fn"]) { + return new Promise((resolve, reject) => { + this.queue.push({ + fn, + resolve, + reject, + }); + + return this.pump(); + }); + } +} \ No newline at end of file diff --git a/frontend/src/lib/communication/ServiceHelper.ts b/frontend/src/lib/communication/ServiceHelper.ts new file mode 100644 index 0000000..402b90e --- /dev/null +++ b/frontend/src/lib/communication/ServiceHelper.ts @@ -0,0 +1,80 @@ +/* + * micro:bit Web Bluetooth + * Copyright (c) 2019 Rob Moran + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/// + +import type { EventEmitter } from "events"; +import PromiseQueue from "./PromiseQueue"; + +export interface Service { + uuid: BluetoothCharacteristicUUID; + create(service: BluetoothRemoteGATTService): Promise; +} + +export default class ServiceHelper { + private queue = new PromiseQueue(); + private characteristics?: BluetoothRemoteGATTCharacteristic[]; + + constructor(private service: BluetoothRemoteGATTService, private emitter: EventEmitter) { } + + async getCharacteristic(uuid: string) { + this.characteristics ??= await this.service.getCharacteristics(); + return this.characteristics.find((characteristic) => characteristic.uuid === uuid); + } + + async getCharacteristicValue(uuid: string) { + const characteristic = await this.getCharacteristic(uuid); + if (!characteristic) throw new Error("Unable to locate characteristic"); + return await this.queue.add(async () => characteristic.readValue()); + } + + async setCharacteristicValue(uuid: string, value: BufferSource) { + const characteristic = await this.getCharacteristic(uuid); + if (!characteristic) throw new Error("Unable to locate characteristic"); + await this.queue.add(async () => characteristic.writeValueWithoutResponse(value)); + } + + async handleListener(event: any, uuid: string, handler: (event: Event) => void) { + const characteristic = await this.getCharacteristic(uuid); + + if (!characteristic) return; + + await this.queue.add(async () => characteristic.startNotifications()); + + this.emitter.on("newListener", (emitterEvent: any) => { + if (emitterEvent !== event || this.emitter.listenerCount(event) > 0) return; + return this.queue.add(async () => + characteristic.addEventListener("characteristicvaluechanged", handler) + ); + }); + + this.emitter.on("removeListener", (emitterEvent: any) => { + if (emitterEvent !== event || this.emitter.listenerCount(event) > 0) return; + return this.queue.add(async () => + characteristic.removeEventListener("characteristicvaluechanged", handler) + ); + }); + } +} \ No newline at end of file diff --git a/frontend/src/lib/communication/UartService.ts b/frontend/src/lib/communication/UartService.ts new file mode 100644 index 0000000..9fe02fe --- /dev/null +++ b/frontend/src/lib/communication/UartService.ts @@ -0,0 +1,111 @@ +/* + * micro:bit Web Bluetooth + * Copyright (c) 2019 Rob Moran + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import EventDispatcher from "./EventDispatcher"; +import ServiceHelper from "./ServiceHelper"; + +/** + * Events raised by the UART service + */ +export interface UartEvents { + /** + * @hidden + */ + newListener: keyof UartEvents; + /** + * @hidden + */ + removeListener: keyof UartEvents; + /** + * Serial data received event + */ + receive: Uint8Array; + /** + * Serial received text event + */ + receiveText: string; +} + +export default class UartService extends EventDispatcher { + static readonly uuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; + + static readonly rx_uuid = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; + + static readonly tx_uuid = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; + + static async create(service: BluetoothRemoteGATTService) { + const bluetoothService = new UartService(service); + await bluetoothService.init(); + return bluetoothService; + } + + private helper: ServiceHelper; + + constructor(service: BluetoothRemoteGATTService) { + super(); + this.helper = new ServiceHelper(service, this); + } + + async init() { + const { tx_uuid } = UartService; + await this.helper.handleListener("receive", tx_uuid, this.receiveHandler.bind(this)); + await this.helper.handleListener("receiveText", tx_uuid, this.receiveTextHandler.bind(this)); + } + + /** + * Send serial data + * @param value The buffer to send + */ + async send(value: BufferSource) { + return this.helper.setCharacteristicValue(UartService.rx_uuid, value); + } + + /** + * Send serial text + * @param value The text to send + */ + async sendText(value: string) { + console.log("sending text", value); + const arrayData = value.split("").map((e) => e.charCodeAt(0)); + return this.helper.setCharacteristicValue( + UartService.rx_uuid, + new Uint8Array(arrayData).buffer + ); + } + + receiveHandler(event: any) { + const view = event.target.value; + const value = new Uint8Array(view.buffer); + this.dispatchEvent("receive", value); + } + + receiveTextHandler(event: any) { + const view = event.target.value; + const numberArray = new Uint8Array(view.buffer).slice() + const value = String.fromCharCode.apply(null, numberArray as any); + console.log("received text", value); + this.dispatchEvent("receiveText", value); + } +} \ No newline at end of file diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..4d6aa96 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,3 @@ +export const ssr = false; +export const prerender = true; +export const trailingSlash = 'always'; \ No newline at end of file diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..9c1d034 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,152 @@ + + +
+ + +
+ +
+ + +
+ +
+ +
+ + diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/frontend/static/favicon.png differ diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..4fbc867 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..0b2d886 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..bbf8c7d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); diff --git a/main.py b/main.py index 7818edd..846ca06 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Optional import azure.cognitiveservices.speech as speechsdk @@ -10,6 +11,7 @@ import os from dotenv import load_dotenv from functools import wraps +from fastapi.middleware.cors import CORSMiddleware # Load environment variables load_dotenv() @@ -20,16 +22,25 @@ description="A voice assistant that converts speech to text, processes it, and returns synthesized speech", version="1.0.0" ) +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8602", "http://doodlebot.media.mit.edu"], # Allows all origins + allow_credentials=True, + allow_methods=["GET", "POST"], # Allows all methods + allow_headers=["*"], # Allows all headers +) # Initialize API clients openai_client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) azure_speech_key = os.getenv('AZURE_SPEECH_KEY') azure_service_region = os.getenv('AZURE_SPEECH_REGION') + class VoiceAssistantError(Exception): """Custom exception for Voice Assistant errors""" pass + def handle_errors(func): """Decorator for error handling""" @wraps(func) @@ -39,9 +50,11 @@ async def wrapper(*args, **kwargs): except VoiceAssistantError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Internal server error: {str(e)}") return wrapper + class VoiceAssistant: def __init__(self): self.conversation_history = [] @@ -49,9 +62,10 @@ def __init__(self): self.openai_client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) self.speech_config = speechsdk.SpeechConfig( subscription=azure_speech_key, - region=azure_service_region + region=azure_service_region, + speech_synthesis_voice_name="en-US-GuyNeural" ) - + # Audio recording config self.CHUNK = 1024 self.FORMAT = pyaudio.paInt16 @@ -62,7 +76,7 @@ def __init__(self): async def record_audio(self) -> bytes: """Record audio from microphone""" p = pyaudio.PyAudio() - + try: stream = p.open( format=self.FORMAT, @@ -71,12 +85,12 @@ async def record_audio(self) -> bytes: input=True, frames_per_buffer=self.CHUNK ) - + frames = [] for _ in range(0, int(self.RATE / self.CHUNK * self.RECORD_SECONDS)): data = stream.read(self.CHUNK) frames.append(data) - + temp_path = os.path.join(self.temp_dir, "temp_recording.wav") wf = wave.open(temp_path, 'wb') wf.setnchannels(self.CHANNELS) @@ -84,12 +98,12 @@ async def record_audio(self) -> bytes: wf.setframerate(self.RATE) wf.writeframes(b''.join(frames)) wf.close() - + with open(temp_path, 'rb') as audio_file: audio_bytes = audio_file.read() - + return audio_bytes - + finally: stream.stop_stream() stream.close() @@ -109,17 +123,18 @@ async def transcribe_audio(self, audio_bytes: bytes) -> str: async def get_chat_response(self, text: str) -> str: """Get response from ChatGPT""" try: - self.conversation_history.append({"role": "user", "content": text}) - + self.conversation_history.append({"role": "user", "content": "start your response with hi student! " + text}) + response = self.openai_client.chat.completions.create( model="gpt-4", messages=self.conversation_history, max_tokens=150 ) - + assistant_response = response.choices[0].message.content - self.conversation_history.append({"role": "assistant", "content": assistant_response}) - + self.conversation_history.append( + {"role": "assistant", "content": assistant_response}) + return assistant_response except Exception as e: raise VoiceAssistantError(f"Chat processing failed: {str(e)}") @@ -128,20 +143,20 @@ async def synthesize_speech(self, text: str) -> str: """Convert text to speech using Azure""" try: output_path = os.path.join(self.temp_dir, "response.wav") - audio_config = speechsdk.audio.AudioOutputConfig(filename=output_path) - + audio_config = speechsdk.audio.AudioOutputConfig( + filename=output_path) synthesizer = speechsdk.SpeechSynthesizer( speech_config=self.speech_config, audio_config=audio_config ) - + result = synthesizer.speak_text_async(text).get() - + if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted: return output_path else: raise VoiceAssistantError("Speech synthesis failed") - + except Exception as e: raise VoiceAssistantError(f"Speech synthesis failed: {str(e)}") @@ -150,13 +165,13 @@ async def process_voice_input(self, audio_data: bytes = None) -> tuple[str, str] try: if audio_data is None: audio_data = await self.record_audio() - + transcript = await self.transcribe_audio(audio_data) response_text = await self.get_chat_response(transcript) audio_path = await self.synthesize_speech(response_text) - + return response_text, audio_path - + except Exception as e: raise VoiceAssistantError(f"Voice processing failed: {str(e)}") @@ -168,15 +183,18 @@ def cleanup(self): except Exception: pass + class ChatResponse(BaseModel): text: str audio_path: str -@app.get("/") + +@app.get("/health") async def root(): """Health check endpoint""" return {"status": "ok", "message": "Voice Assistant API is running"} + @app.post("/chat", response_model=ChatResponse) @handle_errors async def chat_endpoint(audio_file: UploadFile = File(None)): @@ -186,18 +204,18 @@ async def chat_endpoint(audio_file: UploadFile = File(None)): audio_data = None if audio_file: audio_data = await audio_file.read() - + response_text, audio_path = await assistant.process_voice_input(audio_data) - + with open(audio_path, 'rb') as f: audio_content = f.read() - + assistant.cleanup() - + temp_response_path = tempfile.mktemp(suffix='.wav') with open(temp_response_path, 'wb') as f: f.write(audio_content) - + return FileResponse( path=temp_response_path, media_type="audio/wav", @@ -209,6 +227,23 @@ async def chat_endpoint(audio_file: UploadFile = File(None)): assistant.cleanup() raise VoiceAssistantError(f"Chat processing failed: {str(e)}") + +def get_static_directory(name: str): + return os.path.join(os.getcwd(), name) + + +def try_mount_static_html(app, name: str, prefix: str = "/"): + directory = get_static_directory(name) + if os.path.exists(directory): + app.mount(prefix, StaticFiles( + directory=directory, html=True), name=name) + print(f"Mounted {name} at {prefix}") + else: + print(f"Directory not found: {directory}") + + +try_mount_static_html(app, "frontend") + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8000)