diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..61b2369816d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +.gitignore +.github +.vscode +.idea +node_modules +**/node_modules +build +dist +**/build +**/dist +coverage +**/coverage +*.log +.env +.env.* +.DS_Store +Thumbs.db +docs +documents +test +**/test +*.md +!README.md +.dockerignore diff --git a/.github/workflows/deploy-uat.yml b/.github/workflows/deploy-uat.yml new file mode 100644 index 00000000000..c7e90d312a1 --- /dev/null +++ b/.github/workflows/deploy-uat.yml @@ -0,0 +1,70 @@ +name: deploy-uat + +on: + push: + branches: [uat] + workflow_dispatch: {} + # Story 7.6: cross-forge trigger — spark-firmware GitLab CI fires this on a vX.Y.Z release + # so the image rebuilds with the freshly published firmware baked into /firmware/. + repository_dispatch: + types: [firmware-release] + +concurrency: + group: uat-deploy + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Short SHA + id: vars + run: echo "sha=sha-$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + # Story 7.6: pull the latest published firmware from the GitLab pkg registry, verify + # sha256 (fail-closed), and generate firmware/manifest.json (incl. signature) into the + # build context so the Dockerfile bakes it into /usr/share/nginx/html/firmware/. + - name: Fetch published firmware + generate manifest + env: + GITLAB_API_BASE: ${{ vars.GITLAB_API_BASE }} + GITLAB_FW_PROJECT_ID: ${{ vars.GITLAB_FW_PROJECT_ID }} + GITLAB_FW_READ_TOKEN: ${{ secrets.GITLAB_FW_READ_TOKEN }} + run: ./scripts/fetch-firmware.sh + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ghcr.io/warutc/scratch-editor:uat + ghcr.io/warutc/scratch-editor:${{ steps.vars.outputs.sha }} + + - name: Deploy over SSH (forced command) + env: + UAT_HOST: ${{ vars.UAT_HOST }} + UAT_USER: ${{ vars.UAT_USER }} + UAT_SSH_KEY: ${{ secrets.UAT_SSH_KEY }} + run: | + install -d -m700 ~/.ssh + printf '%s\n' "$UAT_SSH_KEY" > ~/.ssh/uat_key + chmod 600 ~/.ssh/uat_key + ssh-keyscan -H "$UAT_HOST" >> ~/.ssh/known_hosts 2>/dev/null \ + || { echo "ssh-keyscan failed for $UAT_HOST" >&2; exit 1; } + ssh -i ~/.ssh/uat_key -o IdentitiesOnly=yes -o ConnectTimeout=15 \ + "$UAT_USER@$UAT_HOST" "deploy ${{ steps.vars.outputs.sha }}" diff --git a/.gitignore b/.gitignore index 780f5bfd972..eb407c9fb13 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,7 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* +.tap/ + +# local git worktrees (superpowers) +.worktrees/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000000..b5d15124b7f --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,79 @@ +# Sparky Scratch Editor — GitLab CI (Story 7.4) +# +# Scope (7.4): test + build only. The current UAT deploy still runs on +# GitHub Actions (`.github/workflows/deploy-uat.yml`); that workflow stays +# alive until **Story 10.3** migrates the image build/push to the GitLab +# Container Registry and wires ArgoCD. Story 7.5 keeps the GH-Actions +# workflow working during the transition window (Node24 action-SHA bump, +# deadline 2026-06-02). DO NOT remove or modify `.github/workflows/*` here. +# +# Cross-references: +# - Story 7.4 (this file) — test/build CI on GitLab +# - Story 7.5 — keep GH-Actions deploy alive (Node24) +# - Story 10.3 — image build/push to GitLab Container +# Registry + ArgoCD + retire deploy-uat.yml +# +# Per-workspace `lint` scripts exist on a subset of workspaces +# (task-herder, scratch-render, scratch-vm, scratch-media-lib-scripts); +# `--if-present` makes the run a no-op on workspaces without one. There is +# NO top-level lint script today; adding one is out of scope for 7.4. + +stages: + - test + - build + +variables: + # Node 24 to match the editor's .nvmrc (24.15.0) and the Node24 bump + # tracked by Story 7.5 for the parallel GH-Actions deploy workflow. + NODE_IMAGE: 'node:24' + # Cache npm's download cache (not node_modules). `npm ci` always wipes and + # re-creates node_modules, so caching node_modules is wasted I/O and can + # leak state across MRs on the same branch. Caching `~/.npm` is the supported + # idiom and gives most of the install speedup. + npm_config_cache: '$CI_PROJECT_DIR/.npm' + +.node_cache: &node_cache + key: + files: + - package-lock.json + prefix: '$CI_COMMIT_REF_SLUG' + paths: + - .npm/ + +test: + stage: test + image: $NODE_IMAGE + cache: + <<: *node_cache + before_script: + - npm ci --prefer-offline + script: + # Per-workspace lint (no-op on workspaces without a `lint` script). + - npm run lint --workspaces --if-present + # `--if-present` so workspaces without a `test` script (asset / tooling + # packages) don't break the pipeline. Lint already uses --if-present above; + # parity here was missing on the initial commit (Story 7.4 code review). + - npm test --workspaces --if-present + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH + +build: + stage: build + image: $NODE_IMAGE + needs: + - test + cache: + <<: *node_cache + policy: pull + before_script: + - npm ci --prefer-offline + script: + - npm run build + artifacts: + paths: + - packages/scratch-gui/build/ + expire_in: 7 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..4c7ea360c9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1.7 +# Story 10.1: Multi-stage image for serving scratch-gui playground from a +# hardened non-root nginx. Build-once-deploy-many via runtime envsubst of +# MIDDLEWARE_WS_URL into /env-config.js. + +# ─── Stage 1: builder ────────────────────────────────────────────────────── +FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS builder +WORKDIR /src + +# Copy full source first. The scratch-gui workspace has a `prepare` lifecycle +# script (scripts/prepare.mjs — extracts media-library zips) that requires the +# source tree to be present, so manifest-only priming won't work. The root +# package's `prepare: husky install` is git-only — we run it skipped via +# --ignore-scripts and then trigger only scratch-gui's prepare explicitly. +COPY . . +RUN npm ci --workspaces --include-workspace-root --ignore-scripts && \ + npm rebuild && \ + npm run --workspace=packages/scratch-gui prepare +ENV NODE_ENV=production +# Build ONLY scratch-gui's dependency closure, in dependency order +# (scratch-svg-renderer → scratch-render → scratch-vm → scratch-gui). +# We deliberately do NOT run the monorepo-wide `npm run build`: the sibling +# `@scratch/task-herder` package builds with rolldown-vite, and rolldown +# 1.0.0-beta.53 fails to resolve its entry module ("[UNRESOLVED_ENTRY]"), +# which aborts the whole workspace build. task-herder is not a dependency of +# scratch-gui and is not shipped in this image, so excluding it is correct +# scoping, not error suppression. (Tracked as a separate task-herder defect.) +# scratch-gui's webpack consumes the upstream packages' `dist/` via their +# `main` fields, so the upstream three must be built first, in this order. +RUN npm run --workspace=packages/scratch-svg-renderer \ + --workspace=packages/scratch-render \ + --workspace=packages/scratch-vm \ + --workspace=packages/scratch-gui build +# Verification gate: fail loudly if the deployable artifact is incomplete, +# so a silently-broken scratch-gui build can never reach the runtime stage. +RUN set -e; \ + d=packages/scratch-gui/build; \ + for f in "$d/index.html" "$d/gui.js"; do \ + test -s "$f" || { echo "FATAL: missing/empty $f" >&2; exit 1; }; \ + done; \ + test -d "$d/chunks" && [ -n "$(ls -A "$d/chunks")" ] || { echo "FATAL: $d/chunks missing/empty" >&2; exit 1; }; \ + test -d "$d/static" && [ -n "$(ls -A "$d/static")" ] || { echo "FATAL: $d/static missing/empty" >&2; exit 1; }; \ + echo "artifact OK: $(du -sh "$d" | cut -f1) in $d" +RUN node -p "require('./package.json').version" > /src/VERSION + +# ─── Stage 2: runtime ────────────────────────────────────────────────────── +FROM nginxinc/nginx-unprivileged:1.27-alpine@sha256:65e3e85dbaed8ba248841d9d58a899b6197106c23cb0ff1a132b7bfe0547e4c0 AS runtime +# Base image USER is already 101 (nginx). No USER root needed — env-config.js +# renders to /tmp (world-writable) and the template reads from /usr/share/... +# (world-readable). /usr/share/nginx/html stays read-only, so a future Helm +# chart can set `readOnlyRootFilesystem: true` without shadowing the assets. + +# Deployable subset of build/ only — drop standalone/player/blocks-only/compatibility +# HTML entries and their ~16 MB sibling bundles (not part of the public web service). +COPY --from=builder /src/packages/scratch-gui/build/index.html /usr/share/nginx/html/ +COPY --from=builder /src/packages/scratch-gui/build/gui.js /usr/share/nginx/html/ +COPY --from=builder /src/packages/scratch-gui/build/gui.js.LICENSE.txt /usr/share/nginx/html/ +COPY --from=builder /src/packages/scratch-gui/build/extension-worker.js /usr/share/nginx/html/ +COPY --from=builder /src/packages/scratch-gui/build/extension-worker.js.LICENSE.txt /usr/share/nginx/html/ +COPY --from=builder /src/packages/scratch-gui/build/30d09ba32a17082ef820b57d52d60b7b.hex /usr/share/nginx/html/ +COPY --from=builder /src/packages/scratch-gui/build/chunks/ /usr/share/nginx/html/chunks/ +COPY --from=builder /src/packages/scratch-gui/build/static/ /usr/share/nginx/html/static/ +COPY --from=builder /src/VERSION /usr/share/nginx/html/VERSION + +# Story 7.6: firmware update artifacts (firmware/manifest.json + .bin) — generated in the +# BUILD CONTEXT by scripts/fetch-firmware.sh (the deploy-uat fetch step), so copy from context, +# NOT --from=builder. Baked so the same nginx serves /firmware/ alongside Scratch (one host). +COPY firmware/ /usr/share/nginx/html/firmware/ + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +# Flat path under /usr/share/ — avoids /etc/nginx/templates/ (the base image's +# 20-envsubst-on-templates.sh would render anything in there to the wrong +# place). Our own 30-spark-env.sh reads from here and writes to /tmp. +COPY docker/env-config.js.template /usr/share/spark.env-config.js.template +COPY --chmod=0755 docker/30-spark-env.sh /docker-entrypoint.d/30-spark-env.sh + +EXPOSE 8080 +# Base image's ENTRYPOINT/CMD already invokes /docker-entrypoint.d/*.sh then nginx -g 'daemon off;'. diff --git a/docker/30-spark-env.sh b/docker/30-spark-env.sh new file mode 100644 index 00000000000..56a4e13eeb3 --- /dev/null +++ b/docker/30-spark-env.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e +: "${MIDDLEWARE_WS_URL:=}" + +# Allow only ws:// or wss:// URLs built from a safe character set. By +# restricting via allow-list we also block the chars that would break the +# single-quoted JS string literal in env-config.js (', `, $, newline). +# Empty is allowed — the Spark extension's || fallback handles it. +if [ -n "$MIDDLEWARE_WS_URL" ]; then + if ! printf '%s' "$MIDDLEWARE_WS_URL" | grep -Eq '^wss?://[A-Za-z0-9._:/?#=&%@~+-]+$'; then + echo "[spark] ERROR: MIDDLEWARE_WS_URL must match ws://|wss:// + [A-Za-z0-9._:/?#=&%@~+-]+ (got: ${MIDDLEWARE_WS_URL})" >&2 + exit 1 + fi +fi + +if [ -f /usr/share/nginx/html/VERSION ]; then + SPARK_VERSION=$(cat /usr/share/nginx/html/VERSION) +else + SPARK_VERSION="dev" +fi +export MIDDLEWARE_WS_URL SPARK_VERSION +envsubst '${MIDDLEWARE_WS_URL} ${SPARK_VERSION}' \ + < /usr/share/spark.env-config.js.template \ + > /tmp/env-config.js +echo "[spark] env-config.js rendered to /tmp: MIDDLEWARE_WS_URL='${MIDDLEWARE_WS_URL}' VERSION='${SPARK_VERSION}'" diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000000..6461fe20bbb --- /dev/null +++ b/docker/README.md @@ -0,0 +1,152 @@ +# scratch-editor Docker image / อิมเมจ Docker สำหรับ scratch-editor + +> **Story 10.1** — Multi-stage build, non-root nginx static-serve, build-once-deploy-many via runtime `MIDDLEWARE_WS_URL` env-var. Foundation for Stories 10.2 (Helm), 10.3 (CI build+push), 10.4 (ArgoCD). + +## §1 ภาพรวม / Overview + +อิมเมจนี้สร้าง bundle ของ `packages/scratch-gui` (Webpack production build) แล้วเสิร์ฟไฟล์ static ผ่าน nginx แบบ non-root (UID 101) บน port 8080 เท่านั้น ไม่มี proxy ไม่มีการ serve API — เบราว์เซอร์ของผู้ใช้เชื่อมต่อ WebSocket ตรงไปยัง middleware-gateway ตาม `MIDDLEWARE_WS_URL` ที่ inject ตอน container start + +This image builds the `packages/scratch-gui` bundle (Webpack production build) and serves the static files via non-root nginx (UID 101) on port 8080 only. No proxying, no API serving — the user's browser opens WebSocket directly to the middleware-gateway at `MIDDLEWARE_WS_URL`, which is injected at container start. + +**AGPL §13:** ผู้ใช้ที่เปิดหน้านี้จะเห็น footer มุมล่างขวาที่ลิงก์ไปยัง public source (`https://github.com/WarutC/scratch-editor`) ตามข้อผูกพันของ AGPL-3.0 สำหรับการ deploy เป็น public network service / Users see a footer linking to the public source repo per AGPL §13. + +**Build-once-deploy-many:** อิมเมจ binary ตัวเดียวกัน deploy ได้ทั้ง staging และ prod ต่างกันแค่ค่า env / The same image binary deploys to staging and prod with only env-var differences (`MIDDLEWARE_WS_URL`). + +## §2 Build the image + +```sh +cd scratch-editor/ +docker build -t scratch-editor:local . +``` + +- เวลาที่ใช้ครั้งแรก: ~5–10 นาที (npm ci ของ monorepo เป็นขั้นที่นานที่สุด) / First run: ~5–10 min (npm ci is the slow step). +- ขนาดอิมเมจ: ดูที่ `docker images scratch-editor:local --format "{{.Size}}"` — เป้าหมาย < 100 MB compressed. + +## §3 Run locally + +```sh +docker run --rm -p 8080:8080 \ + -e MIDDLEWARE_WS_URL=ws://host.docker.internal:8080 \ + scratch-editor:local +``` + +เปิด `http://localhost:8080` → ควรเห็น Scratch GUI พร้อมหมวด `สปาร์ก` (Spark) ในรายการ extension ป้ายภาษาไทยต้องแสดงผลถูกต้อง (ทดสอบ `applySparkTranslations()`) Console error สำหรับ WebSocket ที่เชื่อมต่อ middleware ไม่ได้นั้น **คาดหวังได้** (ไม่มี middleware รันบนเครื่อง smoke-test) ไม่ใช่ regression + +Open `http://localhost:8080` → expect Scratch GUI with Spark category visible. WebSocket connect errors are expected when no middleware is running. + +## §4 Verify env-config injection + +```sh +curl http://localhost:8080/env-config.js +``` + +ผลลัพธ์ที่คาดหวัง / Expected: + +```js +window.SPARK_ENV = { + MIDDLEWARE_WS_URL: 'ws://host.docker.internal:8080', + VERSION: '13.7.1' +}; +``` + +รันใหม่ด้วย `-e MIDDLEWARE_WS_URL=wss://prod.example/ws` → ค่าของ `/env-config.js` ต้องเปลี่ยนตาม (พิสูจน์ build-once-deploy-many) / Re-run with a different value to verify the same image binary serves different config. + +## §5 Healthz + +```sh +curl -i http://localhost:8080/healthz +# HTTP/1.1 200 OK +# Content-Type: text/plain +# ... +# ok +``` + +ใช้สำหรับ Kubernetes `livenessProbe`/`readinessProbe` ใน Helm chart (Story 10.2). + +## §6 Verify AGPL footer + +โหลด `/` → ดูที่มุมล่างขวา → ควรเห็น `"ซอร์สโค้ด / Source"` ลิงก์ไปยัง `https://github.com/WarutC/scratch-editor` พร้อมเลข version จาก `env-config.js` + +Load `/` → bottom-right footer → click `"ซอร์สโค้ด / Source"` → opens public source repo in a new tab. + +## §7 K8s deployment notes (สำหรับ Story 10.2) + +**Required Pod `securityContext`:** + +```yaml +securityContext: + runAsNonRoot: true + runAsUser: 101 + readOnlyRootFilesystem: true # OK — see writable mounts below + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] +``` + +**Required writable `emptyDir` mounts** (เนื่องจาก `readOnlyRootFilesystem: true`): + +| Mount path | เหตุผล / Reason | +|------------|------| +| `/tmp` | nginx temp files AND the rendered `env-config.js` (the `/env-config.js` route is served via nginx `alias /tmp/env-config.js;`) | +| `/var/cache/nginx` | nginx proxy/fastcgi cache dirs (แม้เราไม่ proxy ก็ตาม nginx ยัง mkdir) | +| `/var/run` | nginx PID file | + +> `/usr/share/nginx/html` stays **read-only**. The entrypoint never writes into it — env-config rendering lives in `/tmp` and nginx aliases the request to that path. Story 10.2's Helm chart owns the actual pod spec; this README provides the contract. + +**Listener port:** 8080 (non-root, no `CAP_NET_BIND_SERVICE` needed). Service should expose `targetPort: 8080`. + +## §8 Pinned digests + +| Stage | Tag | Digest | +|-------|-----|--------| +| builder | `node:24.15.0-alpine` | `sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f` | +| runtime | `nginxinc/nginx-unprivileged:1.27-alpine` | `sha256:65e3e85dbaed8ba248841d9d58a899b6197106c23cb0ff1a132b7bfe0547e4c0` | + +Verify (reviewer step): + +```sh +docker pull node:24.15.0-alpine && \ + docker inspect node:24.15.0-alpine --format='{{index .RepoDigests 0}}' +docker pull nginxinc/nginx-unprivileged:1.27-alpine && \ + docker inspect nginxinc/nginx-unprivileged:1.27-alpine --format='{{index .RepoDigests 0}}' +``` + +## §9 What's NOT in the image + +- `node_modules`, source `.js`/`.jsx`/`.css`, the webpack build toolchain (stripped at runtime stage) +- `dist/` UMD library bundles (not used for the public web service) +- `standalone.html` / `player.html` / `blocks-only.html` / `compatibility-testing.html` and their ~16 MB sibling JS bundles +- Source maps +- Any `.env*`, `.git`, `docs/`, `documents/`, `test/` + +**Why:** image-size leanness + reduced attack surface. Only the playground main entry (`index.html` + `gui.js` + `chunks/` + `static/` + `extension-worker.js` + the micro:bit firmware blob) ships. + +## §10 Security posture + +- `server_tokens off` → no `Server: nginx/X.Y.Z` header leak +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Content-Security-Policy: frame-ancestors 'none'` (anti-clickjacking; the GUI is not designed to be embedded) +- gzip enabled for text/JS/CSS/SVG/JSON (gui.js is ~16 MB → ~3–4 MB on the wire) +- No proxy/upstream blocks — pure static serve +- No build args or runtime env accept secrets — only `MIDDLEWARE_WS_URL` (public URL) and `SPARK_VERSION` (baked from `package.json`) + +## §11 Input validation + +`MIDDLEWARE_WS_URL` is validated by the entrypoint script before render: + +- Must start with `ws://` or `wss://`, OR be empty (empty triggers the Spark extension's localhost fallback). +- Must NOT contain `'`, `` ` ``, `$`, or newlines (these would break out of the JS string literal in `env-config.js`). + +Invalid values cause the container to exit with a non-zero code and a clear error in `docker logs`. This is intentional — fail-fast under a misconfigured Helm value beats silently serving the wrong WS URL. + +## §12 Smoke checklist + +- [ ] `docker build -t scratch-editor:local .` succeeds +- [ ] `docker run --rm -p 8080:8080 scratch-editor:local` starts; logs show `[spark] env-config.js rendered: ...` +- [ ] `http://localhost:8080/` → Scratch GUI renders, Spark category visible with Thai labels +- [ ] `curl http://localhost:8080/healthz` → `200 ok` +- [ ] `curl -I http://localhost:8080/` → security headers present, no `Server` version +- [ ] `curl http://localhost:8080/env-config.js` → rendered JS literal with expected values +- [ ] `docker inspect scratch-editor:local --format='{{.Config.User}}'` → `101` +- [ ] `docker stop ` returns within 10 s (clean SIGTERM) diff --git a/docker/env-config.js.template b/docker/env-config.js.template new file mode 100644 index 00000000000..036b0250444 --- /dev/null +++ b/docker/env-config.js.template @@ -0,0 +1,4 @@ +window.SPARK_ENV = { + MIDDLEWARE_WS_URL: '${MIDDLEWARE_WS_URL}', + VERSION: '${SPARK_VERSION}' +}; diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000000..acee2f7dd69 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,94 @@ +# nginx-level add_header inherits to a `location` block ONLY if that block has +# no add_header of its own (long-standing nginx gotcha). We therefore declare +# the security headers via a re-usable map+block trick: every location that +# adds *any* header repeats the security trio explicitly. + +server { + listen 8080 default_server; + server_name _; + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + # Server-level defaults (apply to any location with NO add_header of its own) + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml; + + location /chunks/ { + # No location-level add_header → server-level security headers inherit. + # Cache-Control needs to be here; once added, security headers stop + # inheriting, so we re-declare them. + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header Cache-Control "public, immutable" always; + expires 1y; + } + location /static/ { + # /static/ mixes content-hashed files with stable-name ones (favicon.ico, + # blocks-media). Drop `immutable` — let mtime validation refresh on deploys. + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header Cache-Control "public" always; + expires 1y; + } + + location = /index.html { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header Cache-Control "no-store" always; + } + + location = /env-config.js { + # Rendered at container start by /docker-entrypoint.d/30-spark-env.sh + # into /tmp/env-config.js. Keeps the html root mountable as readOnly. + alias /tmp/env-config.js; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header Cache-Control "no-store" always; + default_type application/javascript; + } + + location = /healthz { + access_log off; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + default_type text/plain; + return 200 "ok\n"; + } + + # Story 7.6: firmware update channel (baked at build by fetch-firmware.sh). The manifest + # is always revalidated so clients see new releases promptly; the versioned .bin is + # immutable. Security-header trio re-declared (nginx add_header inheritance gotcha — see top). + location = /firmware/manifest.json { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header Cache-Control "no-cache" always; + default_type application/json; + } + location /firmware/ { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header Cache-Control "public, immutable" always; + expires 1y; + default_type application/octet-stream; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000000..e43b0f98895 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/examples/1-sensors/imu/IMU_Pitch_test.sb3 b/examples/1-sensors/imu/IMU_Pitch_test.sb3 new file mode 100644 index 00000000000..ddb53a6314a Binary files /dev/null and b/examples/1-sensors/imu/IMU_Pitch_test.sb3 differ diff --git a/examples/1-sensors/imu/IMU_roll_test.sb3 b/examples/1-sensors/imu/IMU_roll_test.sb3 new file mode 100644 index 00000000000..d67433bafcd Binary files /dev/null and b/examples/1-sensors/imu/IMU_roll_test.sb3 differ diff --git a/examples/1-sensors/imu/SparkyGame_ShakeCounter.sb3 b/examples/1-sensors/imu/SparkyGame_ShakeCounter.sb3 new file mode 100644 index 00000000000..fd5493ca2c8 Binary files /dev/null and b/examples/1-sensors/imu/SparkyGame_ShakeCounter.sb3 differ diff --git a/examples/1-sensors/light/DayNight_v3_Sparky.sb3 b/examples/1-sensors/light/DayNight_v3_Sparky.sb3 new file mode 100644 index 00000000000..a8919615018 Binary files /dev/null and b/examples/1-sensors/light/DayNight_v3_Sparky.sb3 differ diff --git a/examples/1-sensors/mic/SoundBarTest_Sparky.sb3 b/examples/1-sensors/mic/SoundBarTest_Sparky.sb3 new file mode 100644 index 00000000000..f83f3dd5924 Binary files /dev/null and b/examples/1-sensors/mic/SoundBarTest_Sparky.sb3 differ diff --git a/examples/1-sensors/tof/AlarmSensor.sb3 b/examples/1-sensors/tof/AlarmSensor.sb3 new file mode 100644 index 00000000000..6a10ec4e503 Binary files /dev/null and b/examples/1-sensors/tof/AlarmSensor.sb3 differ diff --git a/examples/1-sensors/tof/DistanceRuler_mm_Sparky.sb3 b/examples/1-sensors/tof/DistanceRuler_mm_Sparky.sb3 new file mode 100644 index 00000000000..716796c237c Binary files /dev/null and b/examples/1-sensors/tof/DistanceRuler_mm_Sparky.sb3 differ diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.sb3 b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.sb3 new file mode 100644 index 00000000000..159c3fd8308 Binary files /dev/null and b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.sb3 differ diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/6f0c9b9f05092d28f36191d7e68d84a3.svg b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/6f0c9b9f05092d28f36191d7e68d84a3.svg new file mode 100755 index 00000000000..61914cef129 --- /dev/null +++ b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/6f0c9b9f05092d28f36191d7e68d84a3.svg @@ -0,0 +1,42 @@ + + + + costume2.1 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/702d6b19295a4135a0cd6c49606e2a44.svg b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/702d6b19295a4135a0cd6c49606e2a44.svg new file mode 100755 index 00000000000..b19d54493a7 --- /dev/null +++ b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/702d6b19295a4135a0cd6c49606e2a44.svg @@ -0,0 +1,42 @@ + + + + costume1.1 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/83a9787d4cb6f3b7632b4ddfebf74367.wav b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/83a9787d4cb6f3b7632b4ddfebf74367.wav new file mode 100755 index 00000000000..fc3b2724a9c Binary files /dev/null and b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/83a9787d4cb6f3b7632b4ddfebf74367.wav differ diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/83c36d806dc92327b9e7049a565c6bff.wav b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/83c36d806dc92327b9e7049a565c6bff.wav new file mode 100755 index 00000000000..45742d5ef6f Binary files /dev/null and b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/83c36d806dc92327b9e7049a565c6bff.wav differ diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/87ec29ad216c0074c731d581c7f40c39.svg b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/87ec29ad216c0074c731d581c7f40c39.svg new file mode 100755 index 00000000000..a74d7c943e0 --- /dev/null +++ b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/87ec29ad216c0074c731d581c7f40c39.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/project.json b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/project.json new file mode 100755 index 00000000000..8fdcac1c109 --- /dev/null +++ b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.unpacked/project.json @@ -0,0 +1 @@ +{"targets":[{"isStage":true,"name":"Stage","variables":{"`jEk@4|i[#Fk?(8x)AV.-rawv":["raw_mm",2224],"`jEk@4|i[#Fk?(8x)AV.-dist":["dist_cm",220.1]},"lists":{},"broadcasts":{},"blocks":{},"comments":{},"currentCostume":0,"costumes":[{"name":"backdrop1","dataFormat":"svg","assetId":"87ec29ad216c0074c731d581c7f40c39","md5ext":"87ec29ad216c0074c731d581c7f40c39.svg","rotationCenterX":240,"rotationCenterY":180}],"sounds":[{"name":"pop","assetId":"83a9787d4cb6f3b7632b4ddfebf74367","dataFormat":"wav","format":"","rate":48000,"sampleCount":1123,"md5ext":"83a9787d4cb6f3b7632b4ddfebf74367.wav"}],"volume":100,"layerOrder":0,"tempo":60,"videoTransparency":50,"videoState":"on","textToSpeechLanguage":null},{"isStage":false,"name":"รถยนต์","variables":{},"lists":{},"broadcasts":{},"blocks":{"d1e67b9239":{"opcode":"event_whenflagclicked","next":"dafc6d1b9e","parent":null,"inputs":{},"fields":{},"shadow":false,"topLevel":true,"x":80,"y":80},"dafc6d1b9e":{"opcode":"data_setvariableto","next":"7d9ff6f35d","parent":"d1e67b9239","inputs":{"VALUE":[1,[10,"0"]]},"fields":{"VARIABLE":["raw_mm","`jEk@4|i[#Fk?(8x)AV.-rawv"]},"shadow":false,"topLevel":false},"7d9ff6f35d":{"opcode":"data_setvariableto","next":"ea3dd747b9","parent":"dafc6d1b9e","inputs":{"VALUE":[1,[10,"0"]]},"fields":{"VARIABLE":["dist_cm","`jEk@4|i[#Fk?(8x)AV.-dist"]},"shadow":false,"topLevel":false},"ea3dd747b9":{"opcode":"Sparky_setLedColor","next":"6beccdb924","parent":"7d9ff6f35d","inputs":{"COLOR":[1,"874ee1d4af"]},"fields":{},"shadow":false,"topLevel":false},"874ee1d4af":{"opcode":"Sparky_menu_ledColors","next":null,"parent":"ea3dd747b9","inputs":{},"fields":{"ledColors":["green",null]},"shadow":true,"topLevel":false},"6beccdb924":{"opcode":"looks_say","next":null,"parent":"ea3dd747b9","inputs":{"MESSAGE":[1,[10,"🚗 เอาวัตถุเข้าใกล้ Sparky!"]]},"fields":{},"shadow":false,"topLevel":false},"580029790f":{"opcode":"event_whenflagclicked","next":"113bb6fa40","parent":null,"inputs":{},"fields":{},"shadow":false,"topLevel":true,"x":489,"y":70},"113bb6fa40":{"opcode":"control_forever","next":null,"parent":"580029790f","inputs":{"SUBSTACK":[2,"c6f46a682b"]},"fields":{},"shadow":false,"topLevel":false},"c6f46a682b":{"opcode":"data_setvariableto","next":"cbc589006d","parent":"113bb6fa40","inputs":{"VALUE":[3,"090b2be4df",[10,"0"]]},"fields":{"VARIABLE":["raw_mm","`jEk@4|i[#Fk?(8x)AV.-rawv"]},"shadow":false,"topLevel":false},"090b2be4df":{"opcode":"Sparky_tofDistance","next":null,"parent":"c6f46a682b","inputs":{},"fields":{},"shadow":false,"topLevel":false},"cbc589006d":{"opcode":"data_setvariableto","next":"5a31c5ac0c","parent":"c6f46a682b","inputs":{"VALUE":[3,"fb66becf6f",[10,"0"]]},"fields":{"VARIABLE":["dist_cm","`jEk@4|i[#Fk?(8x)AV.-dist"]},"shadow":false,"topLevel":false},"fb66becf6f":{"opcode":"operator_divide","next":null,"parent":"cbc589006d","inputs":{"NUM1":[3,"b34be74103",[4,"0"]],"NUM2":[1,[4,"10"]]},"fields":{},"shadow":false,"topLevel":false},"b34be74103":{"opcode":"Sparky_tofDistance","next":null,"parent":"fb66becf6f","inputs":{},"fields":{},"shadow":false,"topLevel":false},"5a31c5ac0c":{"opcode":"looks_setsizeto","next":"215afde0b4","parent":"cbc589006d","inputs":{"SIZE":[3,"204f8b0244",[4,"100"]]},"fields":{},"shadow":false,"topLevel":false},"204f8b0244":{"opcode":"operator_subtract","next":null,"parent":"5a31c5ac0c","inputs":{"NUM1":[1,[4,"200"]],"NUM2":[3,[12,"dist_cm","`jEk@4|i[#Fk?(8x)AV.-dist"],[4,"0"]]},"fields":{},"shadow":false,"topLevel":false},"215afde0b4":{"opcode":"control_if_else","next":null,"parent":"5a31c5ac0c","inputs":{"CONDITION":[2,"70798940ec"],"SUBSTACK":[2,"5e2f7cb45a"],"SUBSTACK2":[2,"6?6*t(CiXG9tfvVbbO@j"]},"fields":{},"shadow":false,"topLevel":false},"70798940ec":{"opcode":"operator_lt","next":null,"parent":"215afde0b4","inputs":{"OPERAND1":[3,"e1d8a70b98",[4,"0"]],"OPERAND2":[1,[4,"100"]]},"fields":{},"shadow":false,"topLevel":false},"e1d8a70b98":{"opcode":"Sparky_tofDistance","next":null,"parent":"70798940ec","inputs":{},"fields":{},"shadow":false,"topLevel":false},"5e2f7cb45a":{"opcode":"Sparky_setLedColor","next":"1edf501ba4","parent":"215afde0b4","inputs":{"COLOR":[1,"898d712de7"]},"fields":{},"shadow":false,"topLevel":false},"898d712de7":{"opcode":"Sparky_menu_ledColors","next":null,"parent":"5e2f7cb45a","inputs":{},"fields":{"ledColors":["red",null]},"shadow":true,"topLevel":false},"1edf501ba4":{"opcode":"Sparky_playTone","next":"c947a27bde","parent":"5e2f7cb45a","inputs":{"FREQ":[1,[4,"1200"]],"DUR":[1,[4,"300"]]},"fields":{},"shadow":false,"topLevel":false},"c947a27bde":{"opcode":"looks_say","next":null,"parent":"1edf501ba4","inputs":{"MESSAGE":[1,[10,"🔴 ชน! < 10cm"]]},"fields":{},"shadow":false,"topLevel":false},"bbbcfcae55":{"opcode":"control_if_else","next":null,"parent":"6?6*t(CiXG9tfvVbbO@j","inputs":{"CONDITION":[2,"04258c3b06"],"SUBSTACK":[2,"6f3db818ad"],"SUBSTACK2":[2,"a6e10793e7"]},"fields":{},"shadow":false,"topLevel":false},"04258c3b06":{"opcode":"operator_lt","next":null,"parent":"bbbcfcae55","inputs":{"OPERAND1":[3,"2d11d4b75f",[4,"0"]],"OPERAND2":[1,[4,"400"]]},"fields":{},"shadow":false,"topLevel":false},"2d11d4b75f":{"opcode":"Sparky_tofDistance","next":null,"parent":"04258c3b06","inputs":{},"fields":{},"shadow":false,"topLevel":false},"6f3db818ad":{"opcode":"Sparky_setLedColor","next":"78bc9c07b8","parent":"bbbcfcae55","inputs":{"COLOR":[1,"36efac851f"]},"fields":{},"shadow":false,"topLevel":false},"36efac851f":{"opcode":"Sparky_menu_ledColors","next":null,"parent":"6f3db818ad","inputs":{},"fields":{"ledColors":["amber",null]},"shadow":true,"topLevel":false},"78bc9c07b8":{"opcode":"Sparky_playTone","next":"e323c30b91","parent":"6f3db818ad","inputs":{"FREQ":[1,[4,"880"]],"DUR":[1,[4,"100"]]},"fields":{},"shadow":false,"topLevel":false},"e323c30b91":{"opcode":"control_wait","next":"94721d56ae","parent":"78bc9c07b8","inputs":{"DURATION":[1,[5,"0.15"]]},"fields":{},"shadow":false,"topLevel":false},"94721d56ae":{"opcode":"Sparky_stopBuzzer","next":"d866465004","parent":"e323c30b91","inputs":{},"fields":{},"shadow":false,"topLevel":false},"d866465004":{"opcode":"control_wait","next":"a8fa1914ad","parent":"94721d56ae","inputs":{"DURATION":[1,[5,"0.25"]]},"fields":{},"shadow":false,"topLevel":false},"a8fa1914ad":{"opcode":"looks_say","next":null,"parent":"d866465004","inputs":{"MESSAGE":[1,[10,"🟡 ระวัง! 20-40cm"]]},"fields":{},"shadow":false,"topLevel":false},"a6e10793e7":{"opcode":"control_if_else","next":null,"parent":"bbbcfcae55","inputs":{"CONDITION":[2,"9b3472b879"],"SUBSTACK":[2,"4126d0c2be"],"SUBSTACK2":[2,"e7b6166153"]},"fields":{},"shadow":false,"topLevel":false},"9b3472b879":{"opcode":"operator_lt","next":null,"parent":"a6e10793e7","inputs":{"OPERAND1":[3,"8046494b14",[4,"0"]],"OPERAND2":[1,[4,"600"]]},"fields":{},"shadow":false,"topLevel":false},"8046494b14":{"opcode":"Sparky_tofDistance","next":null,"parent":"9b3472b879","inputs":{},"fields":{},"shadow":false,"topLevel":false},"4126d0c2be":{"opcode":"Sparky_setLedColor","next":"55b9b91563","parent":"a6e10793e7","inputs":{"COLOR":[1,"751928a30f"]},"fields":{},"shadow":false,"topLevel":false},"751928a30f":{"opcode":"Sparky_menu_ledColors","next":null,"parent":"4126d0c2be","inputs":{},"fields":{"ledColors":["green",null]},"shadow":true,"topLevel":false},"55b9b91563":{"opcode":"Sparky_playTone","next":"34d2d6548b","parent":"4126d0c2be","inputs":{"FREQ":[1,[4,"660"]],"DUR":[1,[4,"100"]]},"fields":{},"shadow":false,"topLevel":false},"34d2d6548b":{"opcode":"control_wait","next":"b515c6a7c4","parent":"55b9b91563","inputs":{"DURATION":[1,[5,"0.15"]]},"fields":{},"shadow":false,"topLevel":false},"b515c6a7c4":{"opcode":"Sparky_stopBuzzer","next":"eae6b4212e","parent":"34d2d6548b","inputs":{},"fields":{},"shadow":false,"topLevel":false},"eae6b4212e":{"opcode":"control_wait","next":"7c971a5ce6","parent":"b515c6a7c4","inputs":{"DURATION":[1,[5,"0.65"]]},"fields":{},"shadow":false,"topLevel":false},"7c971a5ce6":{"opcode":"looks_say","next":null,"parent":"eae6b4212e","inputs":{"MESSAGE":[1,[10,"🟢 เตือน 40-60cm"]]},"fields":{},"shadow":false,"topLevel":false},"f95bc478f2":{"opcode":"Sparky_setLedColor","next":"e7b6166153","parent":"a6e10793e7","inputs":{"COLOR":[1,"64a666bf45"]},"fields":{},"shadow":false,"topLevel":false},"64a666bf45":{"opcode":"Sparky_menu_ledColors","next":null,"parent":"f95bc478f2","inputs":{},"fields":{"ledColors":["green",null]},"shadow":true,"topLevel":false},"e7b6166153":{"opcode":"Sparky_stopBuzzer","next":"72a13b0ab6","parent":"f95bc478f2","inputs":{},"fields":{},"shadow":false,"topLevel":false},"72a13b0ab6":{"opcode":"looks_say","next":null,"parent":"e7b6166153","inputs":{"MESSAGE":[1,[10,"✅ ปลอดภัย > 60cm"]]},"fields":{},"shadow":false,"topLevel":false},"6?6*t(CiXG9tfvVbbO@j":{"opcode":"control_if_else","next":null,"parent":"215afde0b4","inputs":{"CONDITION":[2,"w}{D2Wr7MpyVH7i$4;7p"],"SUBSTACK":[2,":6fPIpgdRHC@AYFoIHqO"],"SUBSTACK2":[2,"bbbcfcae55"]},"fields":{},"shadow":false,"topLevel":false},"w}{D2Wr7MpyVH7i$4;7p":{"opcode":"operator_lt","next":null,"parent":"6?6*t(CiXG9tfvVbbO@j","inputs":{"OPERAND1":[3,"m}e(C$#|Fon73GDOmD`N",[4,"0"]],"OPERAND2":[1,[4,"200"]]},"fields":{},"shadow":false,"topLevel":false},"m}e(C$#|Fon73GDOmD`N":{"opcode":"Sparky_tofDistance","next":null,"parent":"w}{D2Wr7MpyVH7i$4;7p","inputs":{},"fields":{},"shadow":false,"topLevel":false},":6fPIpgdRHC@AYFoIHqO":{"opcode":"Sparky_setLedColor","next":"(|V3*Pb}k+|T-]7`tf(d","parent":"6?6*t(CiXG9tfvVbbO@j","inputs":{"COLOR":[1,"Ya%pM.pkIoCo(?jh?DM4"]},"fields":{},"shadow":false,"topLevel":false},"Ya%pM.pkIoCo(?jh?DM4":{"opcode":"Sparky_menu_ledColors","next":null,"parent":":6fPIpgdRHC@AYFoIHqO","inputs":{},"fields":{"ledColors":["amber",null]},"shadow":true,"topLevel":false},"(|V3*Pb}k+|T-]7`tf(d":{"opcode":"Sparky_playTone","next":"S?92wuNdyX@;@zN7Fp9W","parent":":6fPIpgdRHC@AYFoIHqO","inputs":{"FREQ":[1,[4,"1200"]],"DUR":[1,[4,"70"]]},"fields":{},"shadow":false,"topLevel":false},"S?92wuNdyX@;@zN7Fp9W":{"opcode":"looks_say","next":null,"parent":"(|V3*Pb}k+|T-]7`tf(d","inputs":{"MESSAGE":[1,[10,"🔴 อันตราย! < 20cm"]]},"fields":{},"shadow":false,"topLevel":false}},"comments":{"d1e67b9239_comment":{"blockId":"d1e67b9239","x":80,"y":-20,"width":260,"height":170,"minimized":false,"text":"🚗 Parking Sensor (mm→cm)\n\nSensor ส่ง mm → แปลงเป็น cm\ndist_cm = tofDistance ÷ 10\n\n🔴 < 200mm (20cm) = ถี่มาก\n🟡 200-400mm = กลาง\n🟢 400-600mm = ช้า\n✅ > 600mm (60cm) = เงียบ"},"580029790f_comment":{"blockId":"580029790f","x":80,"y":230,"width":240,"height":125,"minimized":false,"text":"🔄 Forever:\n1. raw_mm = tofDistance\n2. dist_cm = raw_mm ÷ 10\n3. size = 200 - dist_cm\n4. if-else 4 zones (threshold mm)\n ใกล้ = เสียงถี่ + LED แดง"}},"currentCostume":0,"costumes":[{"name":"costume1","bitmapResolution":1,"dataFormat":"svg","assetId":"702d6b19295a4135a0cd6c49606e2a44","md5ext":"702d6b19295a4135a0cd6c49606e2a44.svg","rotationCenterX":48,"rotationCenterY":50},{"name":"costume2","bitmapResolution":1,"dataFormat":"svg","assetId":"6f0c9b9f05092d28f36191d7e68d84a3","md5ext":"6f0c9b9f05092d28f36191d7e68d84a3.svg","rotationCenterX":46,"rotationCenterY":53}],"sounds":[{"name":"Meow","assetId":"83c36d806dc92327b9e7049a565c6bff","dataFormat":"wav","format":"","rate":48000,"sampleCount":40681,"md5ext":"83c36d806dc92327b9e7049a565c6bff.wav"}],"volume":100,"layerOrder":1,"visible":true,"x":0,"y":0,"size":5.253260863727595,"direction":90,"draggable":false,"rotationStyle":"all around"}],"monitors":[{"id":"`jEk@4|i[#Fk?(8x)AV.-dist","mode":"large","opcode":"data_variable","params":{"VARIABLE":"dist_cm"},"spriteName":null,"value":220.1,"width":0,"height":0,"x":10,"y":60,"visible":true,"sliderMin":0,"sliderMax":200,"isDiscrete":false},{"id":"`jEk@4|i[#Fk?(8x)AV.-rawv","mode":"default","opcode":"data_variable","params":{"VARIABLE":"raw_mm"},"spriteName":null,"value":2224,"width":0,"height":0,"x":10,"y":55,"visible":true,"sliderMin":0,"sliderMax":2000,"isDiscrete":false}],"extensions":["Sparky"],"meta":{"semver":"3.0.0","vm":"13.7.1","agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"}} \ No newline at end of file diff --git a/examples/1-sensors/tof/ParkingSensor_mm_Sparky.zip b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.zip new file mode 100644 index 00000000000..159c3fd8308 Binary files /dev/null and b/examples/1-sensors/tof/ParkingSensor_mm_Sparky.zip differ diff --git a/examples/2-ai-camera/face-id/README.md b/examples/2-ai-camera/face-id/README.md new file mode 100644 index 00000000000..ad824a49c63 --- /dev/null +++ b/examples/2-ai-camera/face-id/README.md @@ -0,0 +1,111 @@ +# ตัวอย่างการใช้บล็อก "รู้จักใคร" (face_id) — Story 4.9 / FR58 + +สามโปรเจกต์ตัวอย่างสำหรับบล็อก `recognise face (takes ~3s)` ที่พร้อมเปิดใน Spark scratch-editor + +| ไฟล์ | เรื่อง | สอนอะไร | +|---|---|---| +| `d-face-id-door.sb3` | ประตูอัจฉริยะ | เทียบรหัสช่องเป็น **ข้อความ** + รายชื่อที่ครูอนุญาต + ประตูความมั่นใจ | +| `e-face-id-checkin.sb3` | เช็คชื่อเข้าเรียน | แปลง **รหัสช่อง → ชื่อคน** ในโปรเจกต์เอง ด้วย 2 รายการคู่กัน | +| `f-face-id-greeting.sb3` | ทักทายอัตโนมัติ | ใช้ `detect face` ที่เร็วเป็น **ประตูกัน** ก่อนเรียกบล็อกที่ช้า ~3 วินาที | + +## ทำไมบล็อกถึงหน้าตาแบบนี้ + +บล็อก `Sparky_aiClassifyFaceId` (index.js:889) เป็น reporter **ไม่มีช่องใส่ค่า** และตอบกลับเป็น +**รหัสช่องทึบ** `person_1` … `person_10` หรือ `person_none` — ไม่เคยตอบเป็นชื่อคน + +- **ไม่มีชื่อคน** เพราะชื่อที่พิมพ์ลงบล็อกจะเดินทางไปพร้อมไฟล์ `.sb3` ที่เด็กแลกกันเล่น + (ฝั่ง middleware ปฏิเสธ field `name` ใน response ด้วย — `tests/faceEnrolSchema.test.js`) +- **ไม่มีบล็อกลงทะเบียนหน้า** การ enroll/forget อยู่ในแผง Advanced ของครูเท่านั้น + middleware ปฏิเสธ `faceEnroll` / `faceForget` ที่มาจากช่อง Scratch (`tests/faceAccess.test.js`) + → โปรเจกต์ "ถามได้ว่านี่ใคร" แต่ "เพิ่มคนไม่ได้" +- **ช้า ~3 วินาที** วัดได้ 2.75 s บนบอร์ด v2 (worst 2,832 ms) เพราะรันโมเดลจดจำต่อจากโมเดลหาหน้า + timeout ของบล็อกตั้งไว้ 8 s (index.js:146) ยาวกว่า router ของ middleware ที่ 6 s โดยตั้งใจ +- **บอร์ดที่ทำไม่ได้ตอบ `person_none`** (FR28) ไม่ error — โปรเจกต์จึงรันต่อได้เสมอ + แต่แปลว่า `person_none` มี 2 ความหมาย: "ไม่รู้จัก" กับ "บอร์ดทำไม่ได้" + +สามตัวอย่างนี้แยกกันคนละประเด็น ไม่ให้ปนกันในไฟล์เดียว + +## D — ประตูอัจฉริยะ + +``` +เมื่อกดปุ่ม A + ใคร = รู้จักใคร (~3 วิ) + ความมั่นใจ = AI confidence + ถ้า <รายชื่อที่เข้าได้ contains ใคร> และ <ความมั่นใจ > 0.6> → ไฟเขียว + เสียง + เปิดประตู + ไม่งั้น → ไฟแดง + บอกรหัสที่เห็น +``` + +- ใช้ **ปุ่ม** เป็นตัวสั่งตรวจ ไม่ใช่ `ทำซ้ำตลอดไป` — 3 วิ/รอบ ทำให้เกมหน่วงทันที +- `รายชื่อที่เข้าได้` เป็นข้อมูลของครู (ค่าเริ่มต้น `person_1`, `person_2`) เพิ่ม/ลบคนแก้ที่รายการนี้ที่เดียว +- `person_none` ไม่เคยอยู่ในรายการ → คนแปลกหน้าและบอร์ดที่ทำไม่ได้ **ปลอดภัยไว้ก่อนทั้งคู่** +- ประตูความมั่นใจกัน "หน้าคล้ายกัน" ที่บอร์ดเดาไปแบบไม่มั่นใจ + +## E — เช็คชื่อเข้าเรียน + +``` +เมื่อกดปุ่ม A + ใคร = รู้จักใคร + ลำดับ = item # of (ใคร) in [รหัส] ← หาไม่เจอได้ 0 + ถ้า ลำดับ = 0 → ยังไม่รู้จัก ให้ครูลงทะเบียนก่อน + ไม่งั้น ชื่อคน = item (ลำดับ) of [ชื่อ] + ถ้า [มาเรียน] contains ชื่อคน → บอกว่าเช็คไปแล้ว + ไม่งั้น → add ชื่อคน to [มาเรียน] +``` + +| รายการ `รหัส` | รายการ `ชื่อ` | +|---|---| +| 1. person_1 | 1. ก้อง | +| 2. person_2 | 2. หนึ่ง | +| 3. person_3 | 3. แนน | +| … | … | + +ลำดับเดียวกัน = คนเดียวกัน ครูลงทะเบียนหน้าไว้ช่องไหน ก็เติมชื่อลำดับนั้นให้ตรง +**ชื่อจริงอยู่ในไฟล์โปรเจกต์ของห้องนั้น ไม่ได้อยู่บนบอร์ด** — ถ้าไม่อยากให้ชื่อติดไปกับไฟล์ ใช้ชื่อเล่นหรือเลขที่แทน + +## F — ทักทายอัตโนมัติ + +``` +ทำซ้ำตลอดไป + เห็นหน้า = detect face ← เร็ว ถามบ่อยได้ + ถ้า เห็นหน้า = face_count_0 → ล้าง "ทักไปแล้ว" ← ไม่มีคน = พร้อมทักคนใหม่ + ไม่งั้น ถ้า "ทักไปแล้ว" ว่าง ← ยังไม่ได้ทักคนนี้ + ใคร = รู้จักใคร (~3 วิ — จุดเดียวในโปรแกรม) + ทักไปแล้ว = ใคร + ทักทายตามชื่อ / ทักแบบทั่วไปถ้าไม่รู้จัก +``` + +สองกลไกที่ต้องมีคู่กันเสมอ: +1. **ประตูกัน** — ไม่มีหน้าอยู่ ก็ไม่ต้องจ่าย 3 วินาที +2. **ตัวจำ (latch)** — คนเดิมยืนอยู่ ไม่ทักซ้ำทุก 3 วินาที เดินออกแล้วกลับมาถึงทักใหม่ + +## เปิดใช้งาน + +1. ครูลงทะเบียนหน้าในแผง Advanced ของ middleware ก่อน (ช่อง 1..10) จดไว้ว่าใครอยู่ช่องไหน +2. เปิด scratch-editor → File → Load from your computer → เลือกไฟล์ `.sb3` +3. ต่อบอร์ด (ปุ่ม Sparky ในแถบซ้าย) แล้วกดธงเขียว / ปุ่ม A บนบอร์ด +4. แก้รายการ `รหัส` / `ชื่อ` / `รายชื่อที่เข้าได้` ให้ตรงกับช่องที่ลงทะเบียนไว้จริง + +> ภาพตัวละครและฉากเป็นของยืมมาจากตัวอย่าง Story 11.x (placeholder) ตั้งใจให้เด็กเปลี่ยนเองในคาบ + +## สร้างใหม่และตรวจสอบ (สำหรับผู้พัฒนา) + +ไฟล์ `.sb3` ในโฟลเดอร์นี้ **ถูกสร้างจากสคริปต์ ไม่ใช่แก้ด้วยมือ** ตัวสร้างอยู่ที่ +`design-artifacts/face-id-examples/` + +```bash +cd design-artifacts/face-id-examples +python3 build_face_id_examples.py # สร้าง .sb3 ทั้งสามไฟล์ใหม่ ลงโฟลเดอร์นี้ +python3 validate_sb3.py # โครงสร้าง: opcode/ตัวแปร/รายการ/asset/hat + วินัย face_id +python3 simulate.py # พฤติกรรม: จำลองบอร์ดตอบ person_N แล้ว assert ผลลัพธ์ +``` + +ถ้าจะแก้ตัวอย่าง (เปลี่ยนชื่อเด็ก เพิ่มช่อง เปลี่ยนข้อความ) ให้แก้ที่สคริปต์แล้วรันใหม่ +ไม่งั้นการรันครั้งถัดไปจะทับงานที่แก้ด้วยมือทิ้ง + +- `validate_sb3.py` บังคับกฎเฉพาะของชุดนี้ด้วย: หนึ่งโปรเจกต์เรียก `Sparky_aiClassifyFaceId` + **ได้ที่เดียว** และห้ามวางไว้เป็นลูกตรง ๆ ของ `ทำซ้ำตลอดไป` +- `simulate.py` ยืนยันสิ่งที่ตัวตรวจโครงสร้างมองไม่เห็น เช่น ความมั่นใจต่ำแล้วประตูต้องไม่เปิด, + เช็คชื่อซ้ำต้องไม่เพิ่มแถว, และไม่มีหน้าอยู่ต้องไม่เรียกบล็อก 3 วินาทีเลยสักครั้ง + +สถานะล่าสุด: validate PASS, simulate PASS (19 ข้อ), และทั้งสามไฟล์เปิดขึ้นจริงใน scratch-editor +(dev server :8601) เห็นบล็อก/คอมเมนต์/มอนิเตอร์ครบ diff --git a/examples/2-ai-camera/face-id/d-face-id-door.sb3 b/examples/2-ai-camera/face-id/d-face-id-door.sb3 new file mode 100644 index 00000000000..b1d8a50c1ac Binary files /dev/null and b/examples/2-ai-camera/face-id/d-face-id-door.sb3 differ diff --git a/examples/2-ai-camera/face-id/e-face-id-checkin.sb3 b/examples/2-ai-camera/face-id/e-face-id-checkin.sb3 new file mode 100644 index 00000000000..f10eba060ee Binary files /dev/null and b/examples/2-ai-camera/face-id/e-face-id-checkin.sb3 differ diff --git a/examples/2-ai-camera/face-id/f-face-id-greeting.sb3 b/examples/2-ai-camera/face-id/f-face-id-greeting.sb3 new file mode 100644 index 00000000000..55da1d72f3c Binary files /dev/null and b/examples/2-ai-camera/face-id/f-face-id-greeting.sb3 differ diff --git a/examples/2-ai-camera/face-play/README.md b/examples/2-ai-camera/face-play/README.md new file mode 100644 index 00000000000..30e5219ed86 --- /dev/null +++ b/examples/2-ai-camera/face-play/README.md @@ -0,0 +1,22 @@ +# Face & Body Play (Epic 11) + +สามเกมที่เล่นด้วยกล้อง ประกอบจากบล็อก `Sparky` ที่ปล่อยไปแล้วทั้งหมด + +| ไฟล์ | เกม | แกนควบคุม | +|---|---|---| +| `a-head-tilt-maze.sb3` | เขาวงกตเอียงหัว | ตำแหน่งหน้า (ซ้าย/ขวา) + **ขนาดหน้า** (โน้มเข้า = ตกเร็ว) | +| `b-face-count-party.sb3` | ปาร์ตี้นับหน้า | จำนวนหน้าที่กล้องเห็น (`face_count_N`) | +| `c-motion-freeze-tag.sb3` | แข่งหยุดนิ่ง | เห็นหน้า + `detect motion` | + +กติกาของเกมมองเห็นได้ในบล็อก ไม่ซ่อนใน custom block และข้อความทั้งหมดเป็นภาษาไทยระดับ ป.5 + +**สร้างจากสคริปต์ ไม่ใช่แก้ด้วยมือ** — ตัวสร้างและบันทึกการออกแบบ (รวมรายการ edge case +E-1..E-9 ที่แก้ไปแล้ว) อยู่ที่ `design-artifacts/face-play-examples/` + +```bash +cd design-artifacts/face-play-examples +python3 build_examples.py && python3 validate_sb3.py && python3 simulate.py +``` + +> อย่าเอา prototype ใน `design-artifacts/face-play-prototypes/` ไปสอน — ชุดนั้นเป็นหลักฐาน +> การวิเคราะห์ ยังมีบั๊กตอนรันจริงที่ชุดนี้แก้แล้ว diff --git a/examples/2-ai-camera/face-play/a-head-tilt-maze.sb3 b/examples/2-ai-camera/face-play/a-head-tilt-maze.sb3 new file mode 100644 index 00000000000..ec95f6d7e32 Binary files /dev/null and b/examples/2-ai-camera/face-play/a-head-tilt-maze.sb3 differ diff --git a/examples/2-ai-camera/face-play/b-face-count-party.sb3 b/examples/2-ai-camera/face-play/b-face-count-party.sb3 new file mode 100644 index 00000000000..26cc65bfe5b Binary files /dev/null and b/examples/2-ai-camera/face-play/b-face-count-party.sb3 differ diff --git a/examples/2-ai-camera/face-play/c-motion-freeze-tag.sb3 b/examples/2-ai-camera/face-play/c-motion-freeze-tag.sb3 new file mode 100644 index 00000000000..f261367ff32 Binary files /dev/null and b/examples/2-ai-camera/face-play/c-motion-freeze-tag.sb3 differ diff --git a/examples/3-scratch-only/Ice Cream Shop 2.sb3 b/examples/3-scratch-only/Ice Cream Shop 2.sb3 new file mode 100644 index 00000000000..e7f561abe6e Binary files /dev/null and b/examples/3-scratch-only/Ice Cream Shop 2.sb3 differ diff --git a/examples/3-scratch-only/Laser Connect! (Puzzle Game).sb3 b/examples/3-scratch-only/Laser Connect! (Puzzle Game).sb3 new file mode 100644 index 00000000000..066ae289e64 Binary files /dev/null and b/examples/3-scratch-only/Laser Connect! (Puzzle Game).sb3 differ diff --git a/examples/3-scratch-only/bike.sb3 b/examples/3-scratch-only/bike.sb3 new file mode 100644 index 00000000000..17fcd5ee4e5 Binary files /dev/null and b/examples/3-scratch-only/bike.sb3 differ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000000..f1d64924216 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,69 @@ +# ตัวอย่างโปรเจกต์ Sparky + +เปิดใน Spark scratch-editor: **File → Load from your computer** แล้วเลือกไฟล์ `.sb3` +ตัวอย่างที่ใช้บอร์ดต้องกดปุ่ม Sparky ในแถบซ้ายเพื่อเชื่อมต่อก่อน + +``` +examples/ +├─ 1-sensors/ เซนเซอร์ทีละตัว — ใช้สอนบล็อกพื้นฐาน +├─ 2-ai-camera/ กล้อง + AI บนบอร์ด — Face & Body, Face ID +├─ 3-scratch-only/ เกม Scratch ล้วน ไม่ต้องต่อบอร์ด +└─ _authoring/ ของสำหรับคนสร้างตัวอย่าง ไม่ใช่ไฟล์สอน +``` + +## 1-sensors — เซนเซอร์ทีละตัว + +| ไฟล์ | เซนเซอร์ | บล็อกหลักที่ใช้ | +|---|---|---| +| `imu/IMU_roll_test.sb3` | IMU | `imuRoll` | +| `imu/IMU_Pitch_test.sb3` | IMU | `imuPitch`, LED | +| `imu/SparkyGame_ShakeCounter.sb3` | IMU | `whenShake`, LED, buzzer | +| `light/DayNight_v3_Sparky.sb3` | Light | `lightLevel`, `whenBright`, LED, buzzer | +| `mic/SoundBarTest_Sparky.sb3` | Mic | `micLevel`, LED | +| `tof/AlarmSensor.sb3` | ToF | `whenNear`, `setTofThreshold`, LED, buzzer | +| `tof/DistanceRuler_mm_Sparky.sb3` | ToF | `tofDistance`, ปุ่ม, LED, buzzer | +| `tof/ParkingSensor_mm_Sparky.sb3` | ToF | `tofDistance`, LED, buzzer | + +`tof/ParkingSensor_mm_Sparky.zip` + `.unpacked/` คือไฟล์เดียวกันในรูปแบบที่แตกไว้แล้ว +(ติดมากับชุดที่ลูกค้าส่งให้ เก็บไว้เผื่ออ้างอิง — ตัวที่ใช้สอนคือ `.sb3`) + +## 2-ai-camera — กล้อง + AI บนบอร์ด + +ต้องใช้บอร์ดที่มีกล้องและเฟิร์มแวร์ที่รองรับ ถ้าบอร์ดทำไม่ได้ บล็อกจะตอบค่า mock +พร้อม toast ภาษาไทยครั้งเดียว (FR28) โปรเจกต์ไม่ error + +| โฟลเดอร์ | ไฟล์ | เรื่อง | +|---|---|---| +| `face-play/` | `a-head-tilt-maze.sb3` | เขาวงกตเอียงหัว — คุมลูกบอลด้วยตำแหน่ง/ขนาดใบหน้า | +| | `b-face-count-party.sb3` | ปาร์ตี้นับหน้า — ฉากเปลี่ยนตามจำนวนคนที่กล้องเห็น | +| | `c-motion-freeze-tag.sb3` | แข่งหยุดนิ่ง — เห็นหน้า + ภาพขยับ = โดนจับ | +| `face-id/` | `d-face-id-door.sb3` | ประตูอัจฉริยะ — รายชื่อที่อนุญาต + ประตูความมั่นใจ | +| | `e-face-id-checkin.sb3` | เช็คชื่อเข้าเรียน — แปลงรหัสช่องเป็นชื่อในโปรเจกต์เอง | +| | `f-face-id-greeting.sb3` | ทักทายอัตโนมัติ — ใช้ `detect face` เป็นประตูกันบล็อกที่ช้า | + +ชุด `face-id/` ต้องให้ครูลงทะเบียนหน้าในแผง Advanced ของ middleware ก่อน +รายละเอียดและข้อจำกัดของบล็อกอยู่ใน `face-id/README.md` + +## 3-scratch-only — ไม่ต้องต่อบอร์ด + +`Ice Cream Shop 2.sb3`, `Laser Connect! (Puzzle Game).sb3`, `bike.sb3` +เกม Scratch ล้วน (ไม่มีบล็อก Sparky เลย) ใช้สอนพื้นฐาน Scratch หรือใช้เป็นตัวตั้งต้นให้เด็กเติมเซนเซอร์เอง + +## _authoring — สำหรับคนสร้างตัวอย่าง + +- `Sparky_System_Prompt.md` — system prompt สำหรับให้ LLM ช่วยออกแบบ `.sb3` ของ Sparky +- `project.json` — โครงโปรเจกต์เปล่าไว้เป็นจุดตั้งต้น + +## หมายเหตุสำหรับผู้พัฒนา + +ไฟล์ใน `2-ai-camera/` **สร้างจากสคริปต์** ไม่ใช่แก้ด้วยมือ: + +| โฟลเดอร์ | ตัวสร้าง | +|---|---| +| `face-play/` | `design-artifacts/face-play-examples/build_examples.py` | +| `face-id/` | `design-artifacts/face-id-examples/build_face_id_examples.py` | + +ทั้งสองชุดมี `validate_sb3.py` (ตรวจโครงสร้าง) และ `simulate.py` (ตรวจพฤติกรรม) อยู่ข้าง ๆ ตัวสร้าง +แก้ตัวอย่าง = แก้สคริปต์แล้วรันใหม่ ไม่งั้นงานที่แก้ด้วยมือจะถูกทับ + +ไฟล์ในโฟลเดอร์ `1-sensors/` และ `3-scratch-only/` มาจากลูกค้า สร้างใหม่จากสคริปต์ไม่ได้ diff --git a/examples/_authoring/Sparky_System_Prompt.md b/examples/_authoring/Sparky_System_Prompt.md new file mode 100644 index 00000000000..578a6a96762 --- /dev/null +++ b/examples/_authoring/Sparky_System_Prompt.md @@ -0,0 +1,246 @@ +# Sparky Educational IoT Device — System Prompt for Scratch .sb3 Generation + +## คุณคือผู้เชี่ยวชาญในการออกแบบเกมการศึกษาสำหรับเด็กด้วย Scratch และอุปกรณ์ IoT ชื่อ "Sparky" + +--- + +## 1. บทบาทและความเชี่ยวชาญ + +- Scratch programming (block-based coding) +- Educational game design สำหรับเด็ก +- STEM / Coding education +- IoT learning devices + +--- + +## 2. อุปกรณ์ Sparky — Sensor ที่มี + +| Sensor | Block หลัก | +|---|---| +| IMU / Gyroscope | roll, pitch, yaw, accel X/Y/Z, gyro X/Y/Z | +| Microphone | mic level, when loud | +| Camera | capture photo to stage | +| Light | light level, when bright | +| TOF Distance | tof distance, when near | +| LED (Output) | set LED color, set LED brightness | +| Buzzer (Output) | play tone, stop buzzer | +| Button | when button pressed, is button pressed? | + +--- + +## 3. PROJECT JSON FORMAT (ต้องใช้ทุกครั้ง ห้ามเปลี่ยน) + +```json +"extensions": ["Sparky"] +``` +⚠️ S ต้องเป็นตัวใหญ่ — "Sparky" ไม่ใช่ "sparky" + +```json +"meta": { + "semver": "3.0.0", + "vm": "13.7.1", + "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" +} +``` + +--- + +## 4. CONFIRMED OPCODES ✅ ทั้งหมด (จาก project.json จริง) + +### 4.1 IMU Sensor + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `roll` | `Sparky_imuRoll` | — | reporter | +| `pitch` | `Sparky_imuPitch` | — | reporter | +| `yaw` | `Sparky_imuYaw` | — | reporter | +| `accel X` | `Sparky_imuAccelX` | — | reporter | +| `accel Y` | `Sparky_imuAccelY` | — | reporter | +| `accel Z` | `Sparky_imuAccelZ` | — | reporter | +| `gyro X` | `Sparky_imuGyroX` | — | reporter | +| `gyro Y` | `Sparky_imuGyroY` | — | reporter | +| `gyro Z` | `Sparky_imuGyroZ` | — | reporter | +| `when shaken` | `Sparky_whenShake` | — | hat block | +| `set IMU fusion to` | `Sparky_setImuFusion` | `ALGO` → menu `Sparky_menu_fusionAlgos` | stack | + +### 4.2 Microphone + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `sound level` | `Sparky_micLevel` | — | reporter | +| `when loud` | `Sparky_whenLoud` | — | hat block | +| `set loud sensitivity` | `Sparky_setMicThreshold` | `LEVEL` → menu `Sparky_menu_sensorLevels` | stack | + +### 4.3 Light Sensor + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `light level` | `Sparky_lightLevel` | — | reporter | +| `when bright` | `Sparky_whenBright` | — | hat block | +| `set bright sensitivity` | `Sparky_setLightThreshold` | `LEVEL` → menu `Sparky_menu_sensorLevels` | stack | + +### 4.4 TOF Distance Sensor + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `nearest distance` | `Sparky_tofDistance` | — | reporter | +| `when object near` | `Sparky_whenNear` | — | hat block | +| `set near sensitivity` | `Sparky_setTofThreshold` | `LEVEL` → menu `Sparky_menu_sensorLevels` | stack | + +### 4.5 LED Output + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `set LED color to` | `Sparky_setLedColor` | `COLOR` → menu `Sparky_menu_ledColors` | stack | +| `set LED brightness to` | `Sparky_setLedBrightness` | `BRIGHTNESS` (number) | stack | + +### 4.6 Buzzer Output + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `play tone Hz for ms` | `Sparky_playTone` | `FREQ`, `DUR` | stack | +| `stop buzzer` | `Sparky_stopBuzzer` | — | stack | + +### 4.7 Camera + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `capture photo to stage` | `Sparky_capturePhoto` | — | stack | + +### 4.8 Button + +| Block ใน Scratch | Opcode | Inputs | หมายเหตุ | +|---|---|---|---| +| `when button pressed` | `Sparky_whenButtonPressed` | `BTN` → menu `Sparky_menu_buttons` | hat block | +| `button pressed?` | `Sparky_isButtonPressed` | `BTN` → menu `Sparky_menu_buttons` | reporter (boolean) | + +--- + +## 5. MENU BLOCKS (shadow=true) — ใช้คู่กับ block หลักเสมอ + +| Menu Opcode | field name | ค่าที่ใช้ได้ | +|---|---|---| +| `Sparky_menu_ledColors` | `ledColors` | `"red"`, `"green"`, `"blue"`, `"yellow"`, `"white"`, `"off"` | +| `Sparky_menu_buttons` | `buttons` | `"A"`, `"B"` | +| `Sparky_menu_sensorLevels` | `sensorLevels` | `"1"` (low), `"2"` (medium), `"3"` (high) | +| `Sparky_menu_fusionAlgos` | `fusionAlgos` | `"complementary"`, `"madgwick"` | + +### ตัวอย่างการเชื่อม menu block กับ block หลัก: +```python +# set LED color to green +LED_ID = uid() +MENU_ID = uid() + +LED_ID: blk("Sparky_setLedColor", next_id, parent_id, + {"COLOR": [1, MENU_ID]}, {}), +MENU_ID: blk("Sparky_menu_ledColors", None, LED_ID, + {}, {"ledColors": ["green", None]}, shadow=True), +``` + +--- + +## 6. CONFIRMED ASSETS (ใช้ได้จริงใน Sparky Scratch) + +``` +cd21514d0531fdffb22204e0ec5ed84a.svg — backdrop เปล่า +bcf454acf82e4504149f7ffe07081dbc.svg — costume แมว 1 +0fb9be3e8397c983338cb71dc84d0b25.svg — costume แมว 2 +83a9787d4cb6f3b7632b4ddfebf74367.wav — เสียง pop +83c36d806dc92327b9e7049a565c6bff.wav — เสียง Meow +``` + +⚠️ ห้ามสร้าง assetId ใหม่เอง เช่น `my_sprite.svg` +⚠️ ต้องใช้เฉพาะ assetId จากรายการนี้เท่านั้น + +--- + +## 7. กฎการสร้างไฟล์ .sb3 + +### 7.1 Block ID +- ใช้ `uuid.uuid4().hex[:10]` ทุกตัว +- ห้ามใช้ชื่อตายตัว + +### 7.2 topLevel blocks ต้องมี x, y +```python +if top: b["x"] = x; b["y"] = y +``` + +### 7.3 Variable ID format +``` +`jEk@4|i[#Fk?(8x)AV.-varname +``` + +### 7.4 Monitor mode +- `"large"` สำหรับตัวเลขหลัก +- `"default"` สำหรับตัวเลขรอง + +--- + +## 8. SPEC CHECK — รันก่อน export ทุกครั้ง + +```python +CONFIRMED_OPS = { + # IMU + 'Sparky_imuRoll','Sparky_imuPitch','Sparky_imuYaw', + 'Sparky_imuAccelX','Sparky_imuAccelY','Sparky_imuAccelZ', + 'Sparky_imuGyroX','Sparky_imuGyroY','Sparky_imuGyroZ', + 'Sparky_whenShake','Sparky_setImuFusion', + # Microphone + 'Sparky_micLevel','Sparky_whenLoud','Sparky_setMicThreshold', + # Light + 'Sparky_lightLevel','Sparky_whenBright','Sparky_setLightThreshold', + # TOF + 'Sparky_tofDistance','Sparky_whenNear','Sparky_setTofThreshold', + # LED + 'Sparky_setLedColor','Sparky_setLedBrightness', + # Buzzer + 'Sparky_playTone','Sparky_stopBuzzer', + # Camera + 'Sparky_capturePhoto', + # Button + 'Sparky_whenButtonPressed','Sparky_isButtonPressed', + # Menus + 'Sparky_menu_ledColors','Sparky_menu_buttons', + 'Sparky_menu_sensorLevels','Sparky_menu_fusionAlgos', +} + +KNOWN_ASSETS = { + 'cd21514d0531fdffb22204e0ec5ed84a.svg', + 'bcf454acf82e4504149f7ffe07081dbc.svg', + '0fb9be3e8397c983338cb71dc84d0b25.svg', + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav', +} + +# ตรวจ: extensions, vm, opcodes, assets, block refs, topLevel x/y +# ถ้าไม่ผ่านทุกข้อ → ห้าม export +``` + +--- + +## 9. BROWSER COMPATIBILITY + +| Browser | ใช้กับ Sparky ได้ | +|---|---| +| Chrome 89+ | ✅ | +| Edge 89+ | ✅ | +| Safari (ทุกเวอร์ชัน) | ❌ | +| Firefox | ❌ | +| Chrome บน iOS | ❌ | + +URL: sparky.ntpsemi.com · Scratch v13.7.1 + +--- + +## 10. WORKFLOW ทุกครั้งก่อน build + +1. เช็คว่า opcode ที่จะใช้อยู่ใน CONFIRMED_OPS ทั้งหมดไหม +2. ถ้ามี opcode ที่ไม่รู้จัก → ขอ project.json จากผู้ใช้ก่อน +3. Build โดยใช้เฉพาะ KNOWN_ASSETS +4. Run SPEC CHECK ทุกข้อ +5. Export เฉพาะเมื่อ PASSED ALL เท่านั้น + +--- + +*อัปเดตล่าสุด: มิถุนายน 2569* +*Confirmed opcodes: 31 opcodes ครบทุก sensor* diff --git a/examples/_authoring/project.json b/examples/_authoring/project.json new file mode 100644 index 00000000000..e14380a6de9 --- /dev/null +++ b/examples/_authoring/project.json @@ -0,0 +1 @@ +{"targets":[{"isStage":true,"name":"Stage","variables":{"`jEk@4|i[#Fk?(8x)AV.-my variable":["my variable",0]},"lists":{},"broadcasts":{},"blocks":{},"comments":{},"currentCostume":0,"costumes":[{"name":"backdrop1","dataFormat":"svg","assetId":"cd21514d0531fdffb22204e0ec5ed84a","md5ext":"cd21514d0531fdffb22204e0ec5ed84a.svg","rotationCenterX":240,"rotationCenterY":180}],"sounds":[{"name":"pop","assetId":"83a9787d4cb6f3b7632b4ddfebf74367","dataFormat":"wav","format":"","rate":48000,"sampleCount":1123,"md5ext":"83a9787d4cb6f3b7632b4ddfebf74367.wav"}],"volume":100,"layerOrder":0,"tempo":60,"videoTransparency":50,"videoState":"on","textToSpeechLanguage":null},{"isStage":false,"name":"Sprite1","variables":{},"lists":{},"broadcasts":{},"blocks":{"Y-QOVde5iUe]@ccF2=Wf":{"opcode":"Sparky_whenShake","next":"I?Jc}JgLSFj,I#cD2TI(","parent":null,"inputs":{},"fields":{},"shadow":false,"topLevel":true,"x":228,"y":104},"I?Jc}JgLSFj,I#cD2TI(":{"opcode":"Sparky_playTone","next":null,"parent":"Y-QOVde5iUe]@ccF2=Wf","inputs":{"FREQ":[1,[4,"440"]],"DUR":[1,[4,"500"]]},"fields":{},"shadow":false,"topLevel":false}},"comments":{},"currentCostume":0,"costumes":[{"name":"costume1","bitmapResolution":1,"dataFormat":"svg","assetId":"bcf454acf82e4504149f7ffe07081dbc","md5ext":"bcf454acf82e4504149f7ffe07081dbc.svg","rotationCenterX":48,"rotationCenterY":50},{"name":"costume2","bitmapResolution":1,"dataFormat":"svg","assetId":"0fb9be3e8397c983338cb71dc84d0b25","md5ext":"0fb9be3e8397c983338cb71dc84d0b25.svg","rotationCenterX":46,"rotationCenterY":53}],"sounds":[{"name":"Meow","assetId":"83c36d806dc92327b9e7049a565c6bff","dataFormat":"wav","format":"","rate":48000,"sampleCount":40681,"md5ext":"83c36d806dc92327b9e7049a565c6bff.wav"}],"volume":100,"layerOrder":1,"visible":true,"x":0,"y":0,"size":100,"direction":90,"draggable":false,"rotationStyle":"all around"}],"monitors":[],"extensions":["Sparky"],"meta":{"semver":"3.0.0","vm":"13.7.1","agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"}} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cb82899bd3a..14412aea43c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9990,31 +9990,45 @@ "license": "BSD-3-Clause" }, "node_modules/@webpack-cli/configtest": { - "version": "1.2.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", "dev": true, "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, "peerDependencies": { - "webpack": "4.x.x || 5.x.x", - "webpack-cli": "4.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" } }, "node_modules/@webpack-cli/info": { - "version": "1.5.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", "dev": true, "license": "MIT", - "dependencies": { - "envinfo": "^7.7.3" + "engines": { + "node": ">=14.15.0" }, "peerDependencies": { - "webpack-cli": "4.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" } }, "node_modules/@webpack-cli/serve": { - "version": "1.7.0", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, "peerDependencies": { - "webpack-cli": "4.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" }, "peerDependenciesMeta": { "webpack-dev-server": { @@ -18044,6 +18058,16 @@ "node": ">= 0.4" } }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/intl": { "version": "1.2.5", "license": "MIT" @@ -29153,14 +29177,16 @@ } }, "node_modules/rechoir": { - "version": "0.7.1", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", "dev": true, "license": "MIT", "dependencies": { - "resolve": "^1.9.0" + "resolve": "^1.20.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/redent": { @@ -34406,7 +34432,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -35304,43 +35329,43 @@ } }, "node_modules/webpack-cli": { - "version": "4.10.0", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", "dependencies": { "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^1.2.0", - "@webpack-cli/info": "^1.5.0", - "@webpack-cli/serve": "^1.7.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", - "commander": "^7.0.0", + "commander": "^10.0.1", "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", - "interpret": "^2.2.0", - "rechoir": "^0.7.0", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "bin": { "webpack-cli": "bin/cli.js" }, "engines": { - "node": ">=10.13.0" + "node": ">=14.15.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "4.x.x || 5.x.x" + "webpack": "5.x.x" }, "peerDependenciesMeta": { "@webpack-cli/generators": { "optional": true }, - "@webpack-cli/migrate": { - "optional": true - }, "webpack-bundle-analyzer": { "optional": true }, @@ -35350,19 +35375,13 @@ } }, "node_modules/webpack-cli/node_modules/commander": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-cli/node_modules/interpret": { - "version": "2.2.0", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">=14" } }, "node_modules/webpack-dev-middleware": { @@ -36301,118 +36320,6 @@ "integrity": "sha512-sf7oGoLuaYAScB4VGr0tzetsYlS8EJH6qnTCfQ/WVEa89hALQ4RQfCKt5xCyPQKPDUbVUAIP1QsxAwfAjlDp7Q==", "extraneous": true }, - "packages/scratch-gui/node_modules/@webpack-cli/configtest": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "packages/scratch-gui/node_modules/@webpack-cli/info": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "packages/scratch-gui/node_modules/@webpack-cli/serve": { - "version": "2.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, - "packages/scratch-gui/node_modules/commander": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "packages/scratch-gui/node_modules/interpret": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "packages/scratch-gui/node_modules/rechoir": { - "version": "0.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "packages/scratch-gui/node_modules/webpack-cli": { - "version": "5.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.1.1", - "@webpack-cli/info": "^2.0.2", - "@webpack-cli/serve": "^2.0.5", - "colorette": "^2.0.14", - "commander": "^10.0.1", - "cross-spawn": "^7.0.3", - "envinfo": "^7.7.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^5.7.3" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "5.x.x" - }, - "peerDependenciesMeta": { - "@webpack-cli/generators": { - "optional": true - }, - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, "packages/scratch-media-lib-scripts": { "name": "@scratch/scratch-media-lib-scripts", "version": "13.7.1", @@ -37814,126 +37721,14 @@ "scratch-render-fonts": "1.0.252" } }, - "packages/scratch-render/node_modules/@webpack-cli/configtest": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "packages/scratch-render/node_modules/@webpack-cli/info": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "packages/scratch-render/node_modules/@webpack-cli/serve": { - "version": "2.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, "packages/scratch-render/node_modules/docdash": { "version": "0.4.0", "dev": true, "license": "Apache-2.0" }, - "packages/scratch-render/node_modules/interpret": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "packages/scratch-render/node_modules/raw-loader": { "version": "0.5.1" }, - "packages/scratch-render/node_modules/rechoir": { - "version": "0.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "packages/scratch-render/node_modules/webpack-cli": { - "version": "5.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.1.1", - "@webpack-cli/info": "^2.0.2", - "@webpack-cli/serve": "^2.0.5", - "colorette": "^2.0.14", - "commander": "^10.0.1", - "cross-spawn": "^7.0.3", - "envinfo": "^7.7.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^5.7.3" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "5.x.x" - }, - "peerDependenciesMeta": { - "@webpack-cli/generators": { - "optional": true - }, - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, - "packages/scratch-render/node_modules/webpack-cli/node_modules/commander": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "packages/scratch-svg-renderer": { "name": "@scratch/scratch-svg-renderer", "version": "13.7.1", @@ -37971,63 +37766,6 @@ "scratch-render-fonts": "1.0.252" } }, - "packages/scratch-svg-renderer/node_modules/@webpack-cli/configtest": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "packages/scratch-svg-renderer/node_modules/@webpack-cli/info": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "packages/scratch-svg-renderer/node_modules/@webpack-cli/serve": { - "version": "2.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, - "packages/scratch-svg-renderer/node_modules/commander": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "packages/scratch-svg-renderer/node_modules/interpret": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "packages/scratch-svg-renderer/node_modules/mkdirp": { "version": "2.1.6", "dev": true, @@ -38042,17 +37780,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/scratch-svg-renderer/node_modules/rechoir": { - "version": "0.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "packages/scratch-svg-renderer/node_modules/rimraf": { "version": "3.0.2", "dev": true, @@ -38067,50 +37794,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/scratch-svg-renderer/node_modules/webpack-cli": { - "version": "5.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.1.1", - "@webpack-cli/info": "^2.0.2", - "@webpack-cli/serve": "^2.0.5", - "colorette": "^2.0.14", - "commander": "^10.0.1", - "cross-spawn": "^7.0.3", - "envinfo": "^7.7.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^5.7.3" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "5.x.x" - }, - "peerDependenciesMeta": { - "@webpack-cli/generators": { - "optional": true - }, - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, "packages/scratch-vm": { "name": "@scratch/scratch-vm", "version": "13.7.1", @@ -38167,7 +37850,7 @@ "tiny-worker": "2.3.0", "typedoc": "0.28.18", "webpack": "5.106.2", - "webpack-cli": "4.10.0", + "webpack-cli": "5.1.4", "webpack-dev-server": "5.2.3" } }, diff --git a/packages/scratch-gui/src/lib/libraries/extensions/index.jsx b/packages/scratch-gui/src/lib/libraries/extensions/index.jsx index 276611ddcca..488132dcd08 100644 --- a/packages/scratch-gui/src/lib/libraries/extensions/index.jsx +++ b/packages/scratch-gui/src/lib/libraries/extensions/index.jsx @@ -49,7 +49,51 @@ import gdxforConnectionSmallIconURL from './gdxfor/gdxfor-small.svg'; import faceSensingIconURL from './faceSensing/faceSensing.png'; import faceSensingInsetIconURL from './faceSensing/faceSensing-small.svg'; +import sparkIconURL from './spark/spark-banner.svg'; +import sparkInsetIconURL from './spark/spark-small.svg'; +import sparkConnectionIconURL from './spark/spark-illustration.svg'; +import sparkConnectionSmallIconURL from './spark/spark-small.svg'; + export default [ + { + name: ( + + ), + extensionId: 'Sparky', + iconURL: sparkIconURL, + insetIconURL: sparkInsetIconURL, + description: ( + + ), + featured: true, + disabled: false, + launchPeripheralConnectionFlow: true, + useAutoScan: true, + connectionIconURL: sparkConnectionIconURL, + connectionSmallIconURL: sparkConnectionSmallIconURL, + prescanMessage: ( + + ), + connectingMessage: ( + + ) + }, { name: ( ), helpLink: 'https://scratch.mit.edu/wedo' + }, + { + name: 'Demo', + extensionId: 'demo', + description: 'Demo extension with example blocks.', + featured: true } ]; diff --git a/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-banner.svg b/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-banner.svg new file mode 100644 index 00000000000..4d722e6ffd0 --- /dev/null +++ b/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-banner.svg @@ -0,0 +1,115 @@ + + + Spark IoT extension + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SPARK + + diff --git a/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-illustration.svg b/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-illustration.svg new file mode 100644 index 00000000000..80d7d1127a0 --- /dev/null +++ b/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-illustration.svg @@ -0,0 +1,72 @@ + + + Spark IoT Kit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-small.svg b/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-small.svg new file mode 100644 index 00000000000..d152e0195c0 --- /dev/null +++ b/packages/scratch-gui/src/lib/libraries/extensions/spark/spark-small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scratch-gui/src/playground/index.ejs b/packages/scratch-gui/src/playground/index.ejs index abbd5d8b024..4a29f3d69be 100644 --- a/packages/scratch-gui/src/playground/index.ejs +++ b/packages/scratch-gui/src/playground/index.ejs @@ -16,6 +16,8 @@ <%= htmlWebpackPlugin.options.title %> + + <% if (htmlWebpackPlugin.options.gtm_id) { %> @@ -23,5 +25,11 @@ <% } %> + + + diff --git a/packages/scratch-vm/package.json b/packages/scratch-vm/package.json index 9539c8df0dc..322dea08c6d 100644 --- a/packages/scratch-vm/package.json +++ b/packages/scratch-vm/package.json @@ -111,7 +111,7 @@ "tiny-worker": "2.3.0", "typedoc": "0.28.18", "webpack": "5.106.2", - "webpack-cli": "4.10.0", + "webpack-cli": "5.1.4", "webpack-dev-server": "5.2.3" } } diff --git a/packages/scratch-vm/src/extension-support/extension-manager.js b/packages/scratch-vm/src/extension-support/extension-manager.js index 6922cdafd4e..adceed17dac 100644 --- a/packages/scratch-vm/src/extension-support/extension-manager.js +++ b/packages/scratch-vm/src/extension-support/extension-manager.js @@ -17,6 +17,7 @@ const builtinExtensions = { wedo2: () => require('../extensions/scratch3_wedo2'), music: () => require('../extensions/scratch3_music'), microbit: () => require('../extensions/scratch3_microbit'), + Sparky: () => require('../extensions/scratch3_spark'), text2speech: () => require('../extensions/scratch3_text2speech'), translate: () => require('../extensions/scratch3_translate'), videoSensing: () => require('../extensions/scratch3_video_sensing'), diff --git a/packages/scratch-vm/src/extensions/scratch3_spark/index.js b/packages/scratch-vm/src/extensions/scratch3_spark/index.js new file mode 100644 index 00000000000..2a414067a6e --- /dev/null +++ b/packages/scratch-vm/src/extensions/scratch3_spark/index.js @@ -0,0 +1,1310 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); +const formatMessage = require('format-message'); +const log = require('../../util/log'); + +const translations = require('./translations'); + +// Merge this extension's block-label translations into format-message's current +// table, preserving every other locale/key already registered. +// +// We can't just call `formatMessage.setup({translations: {th: ...}})` once at +// module load: scratch-vm's `VirtualMachine.setLocale(locale, messages)` does +// `formatMessage.setup({translations: {[locale]: messages}})`, and +// format-message's `setup` *replaces* the whole `translations` object — so the +// GUI's locale init/change (which passes the scratch-l10n editor messages, with +// no `spark.*` keys) wipes our strings and the blocks fall back to their English +// `default:` text. Re-applying the merge at the top of `getInfo()` (called by +// the GUI after `setLocale`, on every palette refresh) keeps our translations +// alive regardless of ordering. Idempotent. +const applySparkTranslations = () => { + const current = formatMessage.setup().translations || {}; + const merged = {}; + Object.keys(current).forEach(loc => { + merged[loc] = Object.assign({}, current[loc]); + }); + Object.keys(translations).forEach(loc => { + merged[loc] = Object.assign({}, merged[loc] || {}, translations[loc]); + }); + formatMessage.setup({translations: merged}); +}; +applySparkTranslations(); + +const EXTENSION_ID = 'Sparky'; +// Story 10.1: env-injected for K8s deployment via runtime /env-config.js; falls back to localhost for Electron desktop. +const WS_URL = (typeof window !== 'undefined' && window.SPARK_ENV && window.SPARK_ENV.MIDDLEWARE_WS_URL) || 'ws://localhost:8080'; +const POLL_INTERVAL_MS = 30; + +// SINGLE SOURCE OF TRUTH for LED colors (R3 2026-05-18, supersedes the +// R2/SCP-2026-05-08 closed enum). Add/raise a color = ONE row here + one +// `spark.color.` line in translations.js. The block menu, default, +// and message-ids all derive from this map. Bi-color red+green matrix: +// firmware ignores b — keep b:0 and express colors as red/green mixes. +const LED_COLOR_MAP = { + red: {r: 255, g: 0, b: 0}, + green: {r: 0, g: 255, b: 0}, + amber: {r: 100, g: 255, b: 0}, // bench-tuned for the red+green LED (2026-05-17); NOT web-amber {255,191,0}, which reads too red on this hardware + off: {r: 0, g: 0, b: 0} +}; +const LED_COLOR_NAMES = Object.keys(LED_COLOR_MAP); +const ledColorMenuItems = () => LED_COLOR_NAMES.map(name => ({ + text: formatMessage({ + id: `spark.color.${name}`, + default: name, + description: `LED color ${name}` + }), + value: name +})); + +// Story 2.8 — which physical LED to address (single source). 'both' → no wire +// `index` field (drives both LEDs, backward-compatible); 'led1'/'led2' → the +// firmware's optional `index` 0/1. The board has two bi-color LEDs. +const LED_TARGETS = [ + {value: 'both', index: null}, + {value: 'led1', index: 0}, + {value: 'led2', index: 1} +]; +const ledTargetMenuItems = () => LED_TARGETS.map(t => ({ + text: formatMessage({id: `spark.ledTarget.${t.value}`, default: t.value, description: `LED target ${t.value}`}), + value: t.value +})); + +const SparkButton = {A: 'A', B: 'B'}; + +// Story 3.4 (FR16) → 3.7/3.8/3.9 (SCP #4) — Thai one-shot warning toast copy +// per Mic/Light/TOF family. The firmware now returns a live {value} on a board +// with Spark-Sensors; on a board without it (or if init failed) it still +// answers hw_not_present, and the reporter falls back to the mock 0 + this +// once-per-family-per-session toast. Branch is on the *response*, not a build +// flag — the same .scratch project works on both substrates. +const STUB_TOAST_TH = { + mic: 'ไมโครโฟนยังไม่พร้อม - แสดงค่าจำลอง 0', + light: 'เซ็นเซอร์แสงยังไม่พร้อม - แสดงค่าจำลอง 0', + tof: 'เซ็นเซอร์ระยะยังไม่พร้อม - แสดงค่าจำลอง 0', + // Story 4.5 — AI (ai.classify) not ready on this board (no camera / model not + // loaded / inference timeout). One-shot per session; reporter returns a mock label. + ai: 'AI ยังไม่พร้อม - แสดงผลจำลอง', + // Story 12.6 (FR45) — QR scanner not available on this board (no camera / + // capability). One-shot per session; the reporter returns '' (mock). + qr: 'เครื่องสแกน QR ยังไม่พร้อม - แสดงผลจำลอง' +}; + +// Story 12.6 (FR49 editor side) — one-shot hint when scanning is ON but nothing +// has decoded for QR_HINT_MS; emitted on the same SPARK_STUB_WARNING bus. +const QR_NO_DECODE_HINT_TH = 'ลองขยับการ์ดเข้าใกล้อีกนิด'; +const QR_HINT_MS = 5000; + +// Map a sensor family to its firmware threshold cmd (whenLoud/whenBright/whenNear). +const SENSOR_THRESHOLD_CMD = { + mic: 'set_mic_threshold', + light: 'set_light_threshold', + tof: 'set_tof_threshold' +}; + +// Story 4.5 — AI color-target menu (single source, like LED_COLOR_MAP). 'any' → +// params.target:null (firmware reports the dominant color). Add a target = one row +// here + one `spark.aiColor.` line in translations.js. +const AI_COLOR_TARGETS = ['any', 'red', 'green', 'blue', 'yellow']; +const aiColorMenuItems = () => AI_COLOR_TARGETS.map(name => ({ + text: formatMessage({id: `spark.aiColor.${name}`, default: name, description: `AI color target ${name}`}), + value: name +})); +// Story 4.5 — AI imu_gesture menu (single source; mirrors AI_COLOR_TARGETS). 'any' → +// params.gesture:null (firmware recognizes any). Non-'any' values match the firmware +// gesture_classify vocabulary (Story 4.2, incl. 'flip'). Add a gesture = one row here + +// one `spark.aiGesture.` line in translations.js. +const AI_GESTURE_TARGETS = ['any', 'shake', 'tilt', 'flat', 'flip']; +const aiGestureMenuItems = () => AI_GESTURE_TARGETS.map(name => ({ + text: formatMessage({id: `spark.aiGesture.${name}`, default: name, description: `AI gesture target ${name}`}), + value: name +})); +// Story 4.5 — face bbox field accessor menu ([x,y,w,h] → one numeric field). +const AI_BBOX_FIELDS = ['x', 'y', 'w', 'h']; +const aiBboxMenuItems = () => AI_BBOX_FIELDS.map(name => ({ + text: formatMessage({id: `spark.aiBboxField.${name}`, default: name, description: `AI bbox field ${name}`}), + value: name +})); +// Mock label returned by each AI reporter when the board can't run inference +// (so a .scratch project keeps working / degrades gracefully — FR28). +const AI_MOCK_LABEL = { + face: 'face_count_0', + color: 'not_found', + motion: 'still', + imu_gesture: 'none', + // Story 4.9: degrading to 'person_none' means a project written against face_id + // behaves on a board without the capability exactly as it does when nobody is + // enrolled — "I don't recognise anyone" — rather than erroring (FR28). + face_id: 'person_none' +}; + +// Story 4.9 (FR58): per-primitive block timeout. face_id runs a second model after +// detection and measured ~2.75 s on board v2 (bench 2026-08-06, worst 2,832 ms; the +// agreed budget is p95 < 3,500 ms). 8 s is deliberately LONGER than the middleware's +// 6 s router timeout for this primitive, so a slow board produces a real error +// response that the block can degrade on, instead of the block giving up first and +// leaving the middleware talking to itself. +const AI_TIMEOUT_MS = {face_id: 8000}; + +class SparkPeripheral { + constructor (runtime, extensionId) { + this._runtime = runtime; + this._extensionId = extensionId; + this._ws = null; + this._buttonState = {0: 0, 1: 0}; + // Story 2.3 (event-push): set by btn_press event; consumed by + // whenButtonPressed HAT so the script fires once per press, not + // every frame the button is held. + this._buttonEdgeLatch = {0: false, 1: false}; + this._pollIntervalId = null; + this._polling = false; + this._lastButtonAccess = 0; + this._pending = new Map(); + // Story 3.2 — last-known IMU values keyed by cmd; used as fallback when + // the firmware returns hw_busy or the WS round-trip times out. + this._imuCache = {imu_accel: null, imu_gyro: null, imu_angle: null}; + this._imuDegraded = false; + // Story 3.3 (event-push): set by the firmware 'shake' event; consumed + // by whenShake HAT so the script fires once per debounced gesture, not + // every frame the threshold is exceeded. Mirror of _buttonEdgeLatch. + // Refractory (500 ms) is enforced firmware-side in task_imu_sampler. + this._shakeEdgeLatch = false; + // Stories 3.7/3.8/3.9 (SCP #4): edge latches for the Spark-Sensors + // HAT blocks, set by the firmware mic_loud / light_bright / tof_near + // events (firmware enforces a 500 ms refractory + rising-edge). Same + // pattern as _shakeEdgeLatch / _buttonEdgeLatch. Reset on disconnect. + this._loudEdgeLatch = false; + this._brightEdgeLatch = false; + this._nearEdgeLatch = false; + // Story 3.4 (FR16): block-families (mic/light/tof) whose Thai + // sensor-pending toast has already been shown this session. Cleared on + // disconnect so a re-connect re-arms the one-shot warnings. + this._stubWarningShown = new Set(); + // Story 4.5 — last ai.classify result {label, confidence, bbox, primitive}; + // the classify reporters return the label, aiConfidence reads confidence. + this._lastAi = null; + // Story 12.6 (Epic 12 QR) — last decoded QR text (FR43 reporter cache; + // '' before any scan, reset on disconnect) + the most recent unconsumed + // sighting for the whenScanned HAT (FR42, edge-latch mirror of _shakeEdgeLatch). + this._lastScannedText = ''; + // 12-7 review P23: a short QUEUE of unconsumed sightings (two decodes + // inside one VM tick used to overwrite each other). Each entry + // {text (trimmed, for FR42 matching), stepMs (VM step that first saw it, + // null until polled)} — see whenScanned for the per-step latch (P22). + this._qrSightings = []; + // FR49 editor side — "nothing decoded for a while" hint bookkeeping. + // One-shot PER SESSION (12-7 review P21, matching the sibling toasts); + // reset in _resetEdgeLatches, not per setQrScan call. + this._qrHintTimer = null; + this._qrHintShown = false; + // 12-7 review P17 — generation token: a capabilities settlement from a + // dead session must not stomp the fresh session's Set. + this._capsGen = 0; + // Story 12.3 (FR48) — the board's announced capability Set, queried at + // connect. null = unknown/legacy firmware (pre-handshake) → blocks are NOT + // gated (backward compat); a Set that lacks 'qr_scan' → FR45 fallback. + this._capabilities = null; + this._runtime.registerPeripheralExtension(extensionId, this); + } + + isConnected () { + return this._ws !== null && this._ws.readyState === 1; // WebSocket.OPEN + } + + scan () { + if (this._ws) this._ws.close(); + this._ws = new WebSocket(WS_URL); + this._ws.onopen = () => { + this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED); + this._queryCapabilities(); // Story 12.3 — learn the board's feature set + }; + this._ws.onmessage = evt => this._onMessage(evt); + this._ws.onerror = () => this._handleDisconnect(); + this._ws.onclose = () => this._handleDisconnect(); + } + + connect () { + // Connection happens in scan() for WebSocket + } + + disconnect () { + this._stopPolling(); + this._stubWarningShown.clear(); // Story 3.4: re-arm one-shot toasts for the next session + this._resetEdgeLatches(); + if (this._ws) { + this._ws.close(); + this._ws = null; + } + } + + // Stories 3.7/3.8/3.9 (+ the deferred _shakeEdgeLatch follow-up): clear all + // HAT edge latches on disconnect so a stale latch from before the drop + // doesn't fire a HAT once on reconnect. + _resetEdgeLatches () { + this._shakeEdgeLatch = false; + this._loudEdgeLatch = false; + this._brightEdgeLatch = false; + this._nearEdgeLatch = false; + this._buttonEdgeLatch = {0: false, 1: false}; + // Story 12.6 — clear the QR sightings + reset the reporter (FR43: + // reset on disconnect) + stop any pending decode-hint timer, and re-arm + // the once-per-session hint (12-7 review P21). + this._qrSightings = []; + this._lastScannedText = ''; + this._clearQrHintTimer(); + this._qrHintShown = false; + // Story 12.3 — forget the announced capabilities; re-queried on reconnect. + // Bumping the generation token invalidates any in-flight query (P17). + this._capsGen++; + this._capabilities = null; + } + + // send(cmd, data) — wraps into {protocol, type, id, cmd, data} per middleware schema + // `timeoutMs` defaults to the 3 s that suits every sensor read. Story 4.9's + // face_id needs more: recognition measured ~2.75 s on hardware (worst 2,832 ms), + // so the 3 s default left ~170 ms of margin and would have timed out + // intermittently — showing a mock label as if the board had answered. + send (cmd, data = {}, timeoutMs = 3000) { + if (!this.isConnected()) return Promise.resolve(null); + const id = Math.random().toString(36) + .slice(2) + Date.now().toString(36); + const msg = {protocol: '1.0', type: 'request', id, cmd, data}; + return new Promise(resolve => { + const timer = setTimeout(() => { + this._pending.delete(id); + resolve(null); + }, timeoutMs); + this._pending.set(id, res => { + clearTimeout(timer); + resolve(res); + }); + this._ws.send(JSON.stringify(msg)); + }); + } + + getButtonState (pin) { + return this._buttonState[pin] || 0; + } + + _onMessage (evt) { + let msg; + try { + msg = JSON.parse(evt.data); + } catch (e) { + log.warn('spark: malformed JSON from peripheral', e); + return; + } + if (msg.type === 'response' && msg.id && this._pending.has(msg.id)) { + const resolve = this._pending.get(msg.id); + this._pending.delete(msg.id); + resolve(msg); + return; + } + if (msg.type === 'event') { + this._onEvent(msg); + } + // heartbeats are intentionally ignored + } + + // Story 2.3 (event-push): firmware emits btn_press/btn_release events + // (one per debounced edge); cache the latched state so the + // whenButtonPressed HAT can fire once per press without continuous-fire + // (which the polling-only path produced). + _onEvent (msg) { + const pin = msg.pin ?? 0; + if (msg.event === 'btn_press') { + this._buttonState[pin] = 1; + this._buttonEdgeLatch[pin] = true; + } else if (msg.event === 'btn_release') { + this._buttonState[pin] = 0; + } else if (msg.event === 'shake') { + // Story 3.3 — one event per debounced gesture (firmware enforces + // the 500 ms refractory). The whenShake HAT consumes the latch. + this._shakeEdgeLatch = true; + } else if (msg.event === 'mic_loud') { + // Stories 3.7/3.8/3.9 — Spark-Sensors HAT events; firmware enforces + // a rising-edge + 500 ms refractory. The whenLoud/whenBright/whenNear + // HATs consume the latch (one fire per event). + this._loudEdgeLatch = true; + } else if (msg.event === 'light_bright') { + this._brightEdgeLatch = true; + } else if (msg.event === 'tof_near') { + this._nearEdgeLatch = true; + } else if (msg.event === 'qr_seen') { + // Story 12.6 (FR42/FR43), reworked by the 12-7 review: + // P20 — the reporter caches the RAW payload (FR43 says "the latest + // scanned text"; trimming is a HAT-matching concern only) + // P12 — whitespace-only payloads are junk: no cache, no latch (a + // blank-target HAT must not fire on them) + // P23 — sightings queue so two decodes inside one VM tick both + // reach their HATs. Firmware enforces leave-and-return. + const raw = typeof msg.text === 'string' ? msg.text : ''; + const trimmed = raw.trim(); + if (trimmed !== '') { + this._lastScannedText = raw; + this._qrSightings.push({text: trimmed, stepMs: null}); + if (this._qrSightings.length > 4) this._qrSightings.shift(); + this._noteQrDecode(); + } + } + } + + // Called by button blocks — records access time and starts polling if idle + touchButtonPoll () { + this._lastButtonAccess = Date.now(); + if (!this._polling && this._pollIntervalId === null) { + this._scheduleNextPoll(); + } + } + + _scheduleNextPoll () { + this._pollIntervalId = setTimeout(() => { + this._pollIntervalId = null; + // Stop if no button block has been called in the last 500ms + if (Date.now() - this._lastButtonAccess > 500) return; + this._pollButtons(); + }, POLL_INTERVAL_MS); + } + + _stopPolling () { + if (this._pollIntervalId !== null) { + clearTimeout(this._pollIntervalId); + this._pollIntervalId = null; + } + this._polling = false; + } + + _pollButtons () { + if (!this.isConnected() || this._polling) return; + this._polling = true; + Promise.all([ + this.send('btn', {pin: 0}), + this.send('btn', {pin: 1}) + ]).then(([resp0, resp1]) => { + if (resp0 && resp0.status === 'ok') this._buttonState[0] = resp0.val ?? 0; + if (resp1 && resp1.status === 'ok') this._buttonState[1] = resp1.val ?? 0; + }) + .catch(() => {}) + .finally(() => { + this._polling = false; + this._scheduleNextPoll(); + }); + } + + // Story 3.2 — fetch a single IMU vector field. Sends `cmd` to the + // middleware (which serves from imuCache, not a fresh firmware round-trip) + // and returns the requested field. On any error / disconnect, falls back + // to the last-known cached value, or 0 if no prior sample. + _readImuField (cmd, field) { + if (!this.isConnected()) { + return Promise.resolve(this._imuCache[cmd]?.[field] ?? 0); + } + return this.send(cmd).then(resp => { + if (resp && resp.status === 'ok') { + if (cmd === 'imu_angle') { + this._imuCache[cmd] = {pitch: resp.pitch, roll: resp.roll, yaw: resp.yaw}; + } else { + this._imuCache[cmd] = {x: resp.x, y: resp.y, z: resp.z}; + } + if (this._imuDegraded) { + log.info(`spark: imu recovered (${cmd})`); + this._imuDegraded = false; + } + return this._imuCache[cmd][field] ?? 0; + } + // Error response (or null timeout) → last-known good + if (!this._imuDegraded) { + log.warn(`spark: imu degraded — falling back to cached values (${resp?.error_code ?? 'timeout'})`); + this._imuDegraded = true; + } + return this._imuCache[cmd]?.[field] ?? 0; + }); + } + + // Stories 3.7/3.8/3.9 (SCP #4) — Mic/Light/TOF reporter. Branch on the + // *response* (not a build flag, so one .scratch works on both substrates): + // - {status:"ok", value:} → Spark-Sensors present: return the + // live value, no toast. + // - {status:"error", error_code:"hw_not_present"} (or timeout) → no + // Spark-Sensors / init failed: return the declared mock 0 and show the + // one-shot Thai warning toast for this family (Story 3.4 fallback path). + _readSensorField (cmd, family) { + if (!this.isConnected()) return Promise.resolve(0); + const fallback = () => { + if (!this._stubWarningShown.has(family)) { + this._stubWarningShown.add(family); + log.warn(`spark: sensor_hw_not_present (${family}) — returning mock 0`); + this._showStubToast(family); + } + return 0; + }; + return this.send(cmd).then(resp => { + if (resp && resp.status === 'ok' && typeof resp.value === 'number') return resp.value; + return fallback(); + }, fallback); + } + + // Stories 3.7/3.8/3.9 — set a HAT threshold (whenLoud/whenBright/whenNear), + // level 1/2/3. Mirrors setShakeSensitivity. No-op (resolves null) if the + // family is unknown or the level isn't 1-3. + _setSensorThreshold (family, level) { + const cmd = SENSOR_THRESHOLD_CMD[family]; + if (!cmd || ![1, 2, 3].includes(level)) return Promise.resolve(null); + return this.send(cmd, {level}); + } + + // Story 4.5 — run an ai.classify primitive. Sends {primitive, params}; on a + // success response caches {label, confidence, bbox} and returns the label. + // Branch on the RESPONSE (not a build flag) so one .scratch works everywhere: + // hw_not_present (no camera) / model_load_failed (primitive not built) / + // inference_timeout / null timeout → return the declared mock label + a + // one-shot Thai 'ai' toast (FR28 graceful degradation, never a Scratch error). + _classify (primitive, params) { + const mock = AI_MOCK_LABEL[primitive] ?? 'not_found'; + // Cache the mock as the last result so the companion reporters + // (aiConfidence / aiBbox) stay coherent with the block that just ran — + // otherwise a degraded call leaves a previous success's confidence/bbox + // stale (Story 4.5 code-review, 2026-07-25). + const cacheMock = () => { + this._lastAi = {label: mock, confidence: 0, bbox: null, primitive}; + return mock; + }; + if (!this.isConnected()) return Promise.resolve(cacheMock()); + const fallback = resp => { + if (!this._stubWarningShown.has('ai')) { + this._stubWarningShown.add('ai'); + log.warn(`spark: ai_not_ready (${primitive}/${resp?.error_code ?? 'timeout'}) — mock label`); + this._showStubToast('ai'); + } + return cacheMock(); + }; + return this.send('ai.classify', {primitive, params}, AI_TIMEOUT_MS[primitive] ?? 3000).then(resp => { + if (resp && resp.status === 'ok' && typeof resp.label === 'string') { + this._lastAi = { + label: resp.label, + confidence: typeof resp.confidence === 'number' ? resp.confidence : 0, + bbox: Array.isArray(resp.bbox) ? resp.bbox : null, + primitive + }; + return resp.label; + } + return fallback(resp); + }, () => fallback(null)); + } + + _showStubToast (family) { + const text = STUB_TOAST_TH[family]; + if (!text) return; + // No native toast bus in scratch-vm yet — emit an in-VM event that the + // scratch-gui side can subscribe to render a snackbar (downstream), + // plus a console fallback. The once-per-family-per-session discipline + // is enforced by the caller's _stubWarningShown Set. + this._runtime.emit('SPARK_STUB_WARNING', {text, family}); + } + + // Story 12.6 (FR49 editor) — one-shot "nothing decoded" hint while scanning. + // The timer is (re)armed when scanning turns on and cancelled by any decode; + // if it elapses first, the hint fires once. Same one-shot bus as the toasts. + _startQrHintTimer () { + this._clearQrHintTimer(); + // NB: _qrHintShown is NOT reset here — the hint is one-shot per session + // (12-7 review P21), re-armed only by _resetEdgeLatches on disconnect. + this._qrHintTimer = setTimeout(() => { + this._qrHintTimer = null; + if (!this._qrHintShown) { + this._qrHintShown = true; + this._runtime.emit('SPARK_STUB_WARNING', {text: QR_NO_DECODE_HINT_TH, family: 'qrHint'}); + } + }, QR_HINT_MS); + } + _noteQrDecode () { + // A card decoded — cancel the pending "nothing decoded" hint. + this._clearQrHintTimer(); + } + _clearQrHintTimer () { + if (this._qrHintTimer) { + clearTimeout(this._qrHintTimer); + this._qrHintTimer = null; + } + } + + // Story 12.3 (FR48) — ask the board for its capability list at connect. A + // pre-handshake firmware answers invalid_cmd (or times out → null) → we stay + // in "unknown/legacy" mode and never gate a block (backward compatible). + _queryCapabilities () { + const gen = ++this._capsGen; // 12-7 review P17 + this.send('capabilities').then(resp => { + if (gen !== this._capsGen) return; // stale settlement from a dead session + this._capabilities = (resp && resp.status === 'ok' && Array.isArray(resp.capabilities)) + ? new Set(resp.capabilities) + : null; + }, () => { + if (gen !== this._capsGen) return; + this._capabilities = null; + }); + } + + // Story 12.3/12.6 — true only when the board announced its features AND the + // set lacks the queried capability. Unknown/legacy (null) → false (don't gate). + _lacksCapability (name) { + return this._capabilities !== null && !this._capabilities.has(name); + } + + _handleDisconnect () { + this._stopPolling(); + this._stubWarningShown.clear(); // Story 3.4: re-arm one-shot toasts for the next session + this._resetEdgeLatches(); + this._pending.forEach(resolve => resolve(null)); + this._pending.clear(); + this._ws = null; + this._runtime.emit(this._runtime.constructor.PERIPHERAL_DISCONNECTED); + } +} + +class Scratch3SparkBlocks { + static get EXTENSION_ID () { + return EXTENSION_ID; + } + + constructor (runtime) { + this.runtime = runtime; + this._peripheral = new SparkPeripheral(runtime, EXTENSION_ID); + } + + getInfo () { + // Re-apply our translations every time the palette is built — the GUI's + // setLocale (which the VM forwards to format-message) replaces the whole + // translations table, so without this our Thai strings get wiped and the + // blocks render their English `default:` text. See applySparkTranslations. + applySparkTranslations(); + return { + id: EXTENSION_ID, + name: formatMessage({id: 'spark.categoryName', default: 'Sparky', description: 'Extension name'}), + showStatusButton: true, + blocks: [ + // ══ OUTPUTS ══════════════════════════════════════════ + // ── LED ───────────────────────────────────────────── + { + opcode: 'setLedColor', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.setLedColor', default: 'set LED [WHICH] color to [COLOR]', description: 'Set LED color (which LED + color)'}), + arguments: { + WHICH: { + type: ArgumentType.STRING, + menu: 'ledTargets', + defaultValue: 'both' + }, + COLOR: { + type: ArgumentType.STRING, + menu: 'ledColors', + defaultValue: 'red' + } + } + }, + { + opcode: 'setLedBrightness', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.setLedBrightness', default: 'set LED [WHICH] brightness to [BRIGHTNESS]', description: 'Set LED brightness 0-255 (which LED + level)'}), + arguments: { + WHICH: { + type: ArgumentType.STRING, + menu: 'ledTargets', + defaultValue: 'both' + }, + BRIGHTNESS: { + type: ArgumentType.NUMBER, + defaultValue: 128 + } + } + }, + '---', + // ── Buzzer ────────────────────────────────────────── + { + opcode: 'playTone', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.playTone', default: 'play tone [FREQ] Hz for [DUR] ms', description: 'Play buzzer tone'}), + arguments: { + FREQ: {type: ArgumentType.NUMBER, defaultValue: 440}, + DUR: {type: ArgumentType.NUMBER, defaultValue: 500} + } + }, + { + opcode: 'stopBuzzer', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.stopBuzzer', default: 'stop buzzer', description: 'Stop buzzer'}) + }, + // ══ major break: OUTPUTS → SENSORS / INPUTS (double '---' = wider gap) ══ + '---', + '---', + // ── Button ────────────────────────────────────────── + { + opcode: 'whenButtonPressed', + blockType: BlockType.HAT, + text: formatMessage({id: 'spark.whenButtonPressed', default: 'when button [BTN] pressed', description: 'Hat: when button pressed'}), + arguments: { + BTN: { + type: ArgumentType.STRING, + menu: 'buttons', + defaultValue: SparkButton.A + } + } + }, + { + opcode: 'isButtonPressed', + blockType: BlockType.BOOLEAN, + text: formatMessage({id: 'spark.isButtonPressed', default: 'button [BTN] pressed?', description: 'Boolean: is button pressed'}), + arguments: { + BTN: { + type: ArgumentType.STRING, + menu: 'buttons', + defaultValue: SparkButton.A + } + } + }, + '---', + // ── Motion · Accelerometer ────────────────────────── + { + opcode: 'imuAccelX', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuAccelX', default: 'accel X', description: 'IMU accelerometer X axis (g)'}) + }, + { + opcode: 'imuAccelY', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuAccelY', default: 'accel Y', description: 'IMU accelerometer Y axis (g)'}) + }, + { + opcode: 'imuAccelZ', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuAccelZ', default: 'accel Z', description: 'IMU accelerometer Z axis (g)'}) + }, + '---', + // ── Motion · Gyroscope ────────────────────────────── + { + opcode: 'imuGyroX', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuGyroX', default: 'gyro X', description: 'IMU gyroscope X axis (deg/s)'}) + }, + { + opcode: 'imuGyroY', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuGyroY', default: 'gyro Y', description: 'IMU gyroscope Y axis (deg/s)'}) + }, + { + opcode: 'imuGyroZ', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuGyroZ', default: 'gyro Z', description: 'IMU gyroscope Z axis (deg/s)'}) + }, + '---', + // ── Motion · Angle ────────────────────────────────── + { + opcode: 'imuPitch', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuPitch', default: 'pitch', description: 'IMU tilt pitch (degrees)'}) + }, + { + opcode: 'imuRoll', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuRoll', default: 'roll', description: 'IMU tilt roll (degrees)'}) + }, + { + // Story 3.11 — tilt-compensated magnetic heading (degrees, -180..180). + opcode: 'imuYaw', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.imuYaw', default: 'yaw', description: 'IMU heading / yaw (degrees, -180..180)'}) + }, + '---', + // ── Motion · Shake gesture + Fusion (Story 3.3 / 3.11) ── + { + opcode: 'whenShake', + blockType: BlockType.HAT, + text: formatMessage({ + id: 'spark.whenShake', + default: 'when shaken', + description: 'Hat: when board is shaken' + }) + }, + { + // Story 3.11 — pick the orientation sensor-fusion algorithm. + opcode: 'setImuFusion', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'spark.setImuFusion', + default: 'set IMU fusion to [ALGO]', + description: 'Select the orientation sensor-fusion algorithm' + }), + arguments: { + ALGO: { + type: ArgumentType.STRING, + menu: 'fusionAlgos', + defaultValue: 'complementary' + } + } + }, + { + opcode: 'setShakeSensitivity', + blockType: BlockType.COMMAND, + text: formatMessage({ + id: 'spark.setShakeSensitivity', + default: 'set shake sensitivity to [LEVEL]', + description: 'Set shake threshold level 1/2/3' + }), + arguments: { + LEVEL: { + type: ArgumentType.STRING, + menu: 'shakeLevels', + defaultValue: '2' + } + } + }, + '---', + // ── Spark-Sensors — Mic / Light / TOF (Stories 3.7/3.8/3.9, SCP #4) ── + // Live on a board with the Spark-Sensors module; on a board + // without it the reporters return mock 0 + a one-shot Thai + // toast and the HATs stay inert (firmware sends no events). The + // branch is on the response, so one .scratch works on both. + // ── Sound (microphone) ────────────────────────────── + { + opcode: 'whenLoud', + blockType: BlockType.HAT, + text: formatMessage({id: 'spark.whenLoud', default: 'when loud', description: 'Hat: when a loud sound happens'}) + }, + { + opcode: 'micLevel', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.micLevel', default: 'sound level', description: 'Mic level reporter (0..100 sound level, dB-mapped from the mic RMS in firmware)'}) + }, + { + opcode: 'setMicThreshold', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.setMicThreshold', default: 'set loud sensitivity to [LEVEL]', description: 'Set whenLoud threshold level 1/2/3'}), + arguments: {LEVEL: {type: ArgumentType.STRING, menu: 'sensorLevels', defaultValue: '2'}} + }, + '---', + // ── Light ─────────────────────────────────────────── + { + opcode: 'whenBright', + blockType: BlockType.HAT, + text: formatMessage({id: 'spark.whenBright', default: 'when bright', description: 'Hat: when it gets bright'}) + }, + { + opcode: 'lightLevel', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.lightLevel', default: 'light level', description: 'Light level reporter (lux)'}) + }, + { + opcode: 'setLightThreshold', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.setLightThreshold', default: 'set bright sensitivity to [LEVEL]', description: 'Set whenBright threshold level 1/2/3'}), + arguments: {LEVEL: {type: ArgumentType.STRING, menu: 'sensorLevels', defaultValue: '2'}} + }, + '---', + // ── Distance (time-of-flight) ─────────────────────── + { + opcode: 'whenNear', + blockType: BlockType.HAT, + text: formatMessage({id: 'spark.whenNear', default: 'when object near', description: 'Hat: when an object comes near'}) + }, + { + opcode: 'tofDistance', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.tofDistance', default: 'nearest distance', description: 'TOF distance reporter (mm; 9999 = no target)'}) + }, + { + opcode: 'setTofThreshold', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.setTofThreshold', default: 'set near sensitivity to [LEVEL]', description: 'Set whenNear threshold level 1/2/3'}), + arguments: {LEVEL: {type: ArgumentType.STRING, menu: 'sensorLevels', defaultValue: '2'}} + }, + // ══ major break: SENSORS → CAMERA (M3 module, delivered later) ══ + '---', + '---', + // ── Camera (M3 — not in the M2 deliverable) ───────── + { + opcode: 'capturePhoto', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.capturePhoto', default: 'capture photo to stage', description: 'Capture camera image to stage'}) + }, + '---', + // ── On-device AI (ai.classify — Stories 4.2/4.3/4.4 firmware) ─────── + // REPORTERS returning the inference label; aiConfidence reads the last + // result's confidence. On a board that can't run a primitive the reporter + // returns a mock label + a one-shot Thai toast (FR28) — never an error. + { + opcode: 'aiClassifyFace', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiClassifyFace', + default: 'detect face', + description: 'AI: on-device face detection (label face_count_N)' + }) + }, + { + opcode: 'aiClassifyColor', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiClassifyColor', + default: 'detect color [TARGET]', + description: 'AI: on-device color detection' + }), + arguments: { + TARGET: {type: ArgumentType.STRING, menu: 'aiColorTargets', defaultValue: 'any'} + } + }, + { + opcode: 'aiClassifyMotion', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiClassifyMotion', + default: 'detect motion (sensitivity [THRESHOLD])', + description: 'AI: on-device motion detection (motion_detected/still)' + }), + arguments: { + THRESHOLD: {type: ArgumentType.NUMBER, defaultValue: 50} + } + }, + { + opcode: 'aiClassifyImuGesture', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiClassifyImuGesture', + default: 'detect gesture [GESTURE]', + description: 'AI: on-device IMU-gesture (the camera-free AI floor)' + }), + arguments: { + GESTURE: {type: ArgumentType.STRING, menu: 'aiGestures', defaultValue: 'any'} + } + }, + { + // Story 4.9 (FR58). Returns an OPAQUE slot label — person_1..person_10, + // or person_none. Never a human name: a name typed into a block would + // travel inside the .sb3 file children share with each other. + // + // Enrolment is NOT a block. It lives in the teacher's Advanced panel + // and the middleware refuses faceEnroll/faceForget on this channel, so + // a project can ask "who is this?" but can never add anyone. + // + // This block takes ~3 s to answer (the recognition model runs after + // detection). That is expected, not a hang — see the Thai label, which + // says so, and Story 4.9 AC3's 3,500 ms budget. + opcode: 'aiClassifyFaceId', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiClassifyFaceId', + default: 'recognise face (takes ~3s)', + description: 'AI: on-device face recognition against teacher-enrolled slots' + }) + }, + { + opcode: 'aiConfidence', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiConfidence', + default: 'AI confidence', + description: 'Confidence (0..1) of the last AI detection' + }) + }, + { + opcode: 'aiBbox', + blockType: BlockType.REPORTER, + text: formatMessage({ + id: 'spark.aiBbox', + default: 'AI box [FIELD]', + description: 'Read x/y/w/h of the last AI detection bbox' + }), + arguments: { + FIELD: {type: ArgumentType.STRING, menu: 'aiBboxFields', defaultValue: 'x'} + } + }, + // ── QR card sensing (Epic 12 — Stories 12.4/12.6) ─── + // Neutral primitives only: turn scanning on/off, a HAT that fires + // on a chosen text, and a reporter with the latest text. No game + // semantics, no card-name dropdown, no pack concept (FR44 design). + '---', + { + opcode: 'setQrScan', + blockType: BlockType.COMMAND, + text: formatMessage({id: 'spark.setQrScan', default: 'turn QR scanning [STATE]', description: 'Start/stop QR card scanning'}), + arguments: {STATE: {type: ArgumentType.STRING, menu: 'qrScanState', defaultValue: 'on'}} + }, + { + opcode: 'whenScanned', + blockType: BlockType.HAT, + text: formatMessage({id: 'spark.whenScanned', default: 'when scanned [TEXT]', description: 'Hat: fires when a QR card with this exact text is scanned'}), + arguments: {TEXT: {type: ArgumentType.STRING, defaultValue: 'เสือ'}} + }, + { + opcode: 'lastScannedText', + blockType: BlockType.REPORTER, + text: formatMessage({id: 'spark.lastScannedText', default: 'last scanned text', description: 'Reporter: the most recently decoded QR text (empty before any scan)'}) + } + ], + menus: { + ledColors: { + acceptReporters: true, + items: ledColorMenuItems() + }, + // Story 2.8 — which physical LED (both / LED 1 / LED 2). + ledTargets: { + acceptReporters: false, + items: ledTargetMenuItems() + }, + buttons: { + acceptReporters: true, + items: [ + {text: 'A', value: SparkButton.A}, + {text: 'B', value: SparkButton.B} + ] + }, + shakeLevels: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'spark.shakeLevel.1', + default: 'gentle', + description: 'Shake sensitivity level 1' + }), + value: '1' + }, + { + text: formatMessage({ + id: 'spark.shakeLevel.2', + default: 'medium', + description: 'Shake sensitivity level 2 (default)' + }), + value: '2' + }, + { + text: formatMessage({ + id: 'spark.shakeLevel.3', + default: 'vigorous', + description: 'Shake sensitivity level 3' + }), + value: '3' + } + ] + }, + // Stories 3.7/3.8/3.9 — shared 1/2/3 sensitivity menu for the + // whenLoud / whenBright / whenNear threshold blocks. Level 1 = + // most sensitive (triggers easily), 3 = least. + sensorLevels: { + acceptReporters: true, + items: [ + {text: formatMessage({id: 'spark.sensorLevel.1', default: 'high', description: 'Sensor sensitivity level 1 (most sensitive)'}), value: '1'}, + {text: formatMessage({id: 'spark.sensorLevel.2', default: 'medium', description: 'Sensor sensitivity level 2 (default)'}), value: '2'}, + {text: formatMessage({id: 'spark.sensorLevel.3', default: 'low', description: 'Sensor sensitivity level 3 (least sensitive)'}), value: '3'} + ] + }, + // Story 12.6 — QR scanning on/off menu. + qrScanState: { + acceptReporters: false, + items: [ + {text: formatMessage({id: 'spark.qrScanState.on', default: 'on', description: 'Start QR scanning'}), value: 'on'}, + {text: formatMessage({id: 'spark.qrScanState.off', default: 'off', description: 'Stop QR scanning'}), value: 'off'} + ] + }, + // Story 3.11 — orientation sensor-fusion algorithm for pitch/roll/yaw. + // 'raw'/'smooth' are friendly names for none/complementary; Kalman/ + // Madgwick/Mahony keep their (proper-noun) names. + fusionAlgos: { + acceptReporters: true, + items: [ + {text: formatMessage({id: 'spark.fusionAlgo.none', default: 'raw', description: 'Fusion: none (accel/mag only)'}), value: 'none'}, + {text: formatMessage({id: 'spark.fusionAlgo.complementary', default: 'smooth', description: 'Fusion: complementary (default)'}), value: 'complementary'}, + {text: formatMessage({id: 'spark.fusionAlgo.kalman', default: 'Kalman', description: 'Fusion: 1-D Kalman'}), value: 'kalman'}, + {text: formatMessage({id: 'spark.fusionAlgo.madgwick', default: 'Madgwick', description: 'Fusion: Madgwick'}), value: 'madgwick'}, + {text: formatMessage({id: 'spark.fusionAlgo.mahony', default: 'Mahony', description: 'Fusion: Mahony'}), value: 'mahony'} + ] + }, + // Story 4.5 — AI color-target menu (single source: AI_COLOR_TARGETS). + aiColorTargets: { + acceptReporters: true, + items: aiColorMenuItems() + }, + // Story 4.5 — AI imu_gesture menu (single source: AI_GESTURE_TARGETS). + aiGestures: { + acceptReporters: true, + items: aiGestureMenuItems() + }, + // Story 4.5 — face bbox field accessor menu (AI_BBOX_FIELDS). + aiBboxFields: { + acceptReporters: false, + items: aiBboxMenuItems() + } + } + }; + } + + setLedColor (args) { + const color = args.COLOR in LED_COLOR_MAP ? LED_COLOR_MAP[args.COLOR] : LED_COLOR_MAP.off; + // Story 2.8 — 'both' (default) omits `index` (both LEDs, backward-compatible); + // 'led1'/'led2' add the firmware's per-LED index 0/1. + const target = LED_TARGETS.find(t => t.value === args.WHICH); + const payload = {pin: 2, ...color}; + if (target && target.index !== null) payload.index = target.index; + return this._peripheral.send('led', payload); + } + + setLedBrightness (args) { + const val = Math.max(0, Math.min(255, Number(args.BRIGHTNESS) || 0)); + // Story 2.8 — 'both' (default) omits index; 'led1'/'led2' → per-LED brightness. + const target = LED_TARGETS.find(t => t.value === args.WHICH); + const payload = {pin: 2, val}; + if (target && target.index !== null) payload.index = target.index; + return this._peripheral.send('pwm', payload); + } + + whenButtonPressed (args) { + if (!this._peripheral.isConnected()) return false; + const pin = args.BTN === 'A' ? 0 : 1; + // Story 2.3 (event-push): consume the edge latch set by btn_press. + // Returns true ONCE per debounced press, not every frame while held. + // Pure event path — no polling fallback. Firmware emits btn_press / + // btn_release per pin via task_button_gpio_input (Spark-Baseboard) or + // task_touch_input (Waveshare touchscreen, pin:0 only). The + // isButtonPressed BOOLEAN block keeps its own polling for + // continuous-state queries. + if (this._peripheral._buttonEdgeLatch[pin]) { + this._peripheral._buttonEdgeLatch[pin] = false; + return true; + } + return false; + } + + isButtonPressed (args) { + if (!this._peripheral.isConnected()) return false; + this._peripheral.touchButtonPoll(); + const pin = args.BTN === 'A' ? 0 : 1; + return this._peripheral.getButtonState(pin) === 1; + } + + playTone (args) { + const freq = Math.max(0, Number(args.FREQ) || 0); + const dur = Math.max(0, Number(args.DUR) || 0); + return this._peripheral.send('buzz', {freq, dur}); + } + + stopBuzzer () { + return this._peripheral.send('buzz', {freq: 0, dur: 0}); + } + + imuAccelX () { + return this._peripheral._readImuField('imu_accel', 'x'); + } + imuAccelY () { + return this._peripheral._readImuField('imu_accel', 'y'); + } + imuAccelZ () { + return this._peripheral._readImuField('imu_accel', 'z'); + } + imuGyroX () { + return this._peripheral._readImuField('imu_gyro', 'x'); + } + imuGyroY () { + return this._peripheral._readImuField('imu_gyro', 'y'); + } + imuGyroZ () { + return this._peripheral._readImuField('imu_gyro', 'z'); + } + imuPitch () { + return this._peripheral._readImuField('imu_angle', 'pitch'); + } + imuRoll () { + return this._peripheral._readImuField('imu_angle', 'roll'); + } + imuYaw () { + // Story 3.11 — tilt-compensated magnetic heading (degrees, -180..180). + return this._peripheral._readImuField('imu_angle', 'yaw'); + } + setImuFusion (args) { + // Story 3.11 — pick the orientation fusion algorithm at runtime. + const algo = args.ALGO; + if (!['none', 'complementary', 'kalman', 'madgwick', 'mahony'].includes(algo)) return Promise.resolve(null); + return this._peripheral.send('set_imu_fusion', {algo}); + } + + whenShake () { + if (!this._peripheral.isConnected()) return false; + // Story 3.3 — pure event path mirroring whenButtonPressed (Story 2.3). + // Consume the latch set by the firmware 'shake' event so the HAT fires + // exactly once per debounced gesture. Refractory (500 ms) is enforced + // firmware-side in task_imu_sampler; this side just edge-latches. + if (this._peripheral._shakeEdgeLatch) { + this._peripheral._shakeEdgeLatch = false; + return true; + } + return false; + } + + setShakeSensitivity (args) { + const level = parseInt(args.LEVEL, 10); + if (![1, 2, 3].includes(level)) return Promise.resolve(null); + return this._peripheral.send('set_shake_threshold', {level}); + } + + // ── Mic / Light / TOF (Stories 3.7/3.8/3.9, SCP #4) — live on a board with + // Spark-Sensors; on a board without it the reporters return mock 0 + a + // one-shot Thai toast and the HATs stay inert (no firmware events). The + // branch is on the response, so the same .scratch works on both. ── + micLevel () { + return this._peripheral._readSensorField('mic_level', 'mic'); + } + lightLevel () { + return this._peripheral._readSensorField('light_level', 'light'); + } + tofDistance () { + return this._peripheral._readSensorField('tof_distance', 'tof'); + } + whenLoud () { + if (!this._peripheral.isConnected()) return false; + if (this._peripheral._loudEdgeLatch) { + this._peripheral._loudEdgeLatch = false; return true; + } + return false; + } + whenBright () { + if (!this._peripheral.isConnected()) return false; + if (this._peripheral._brightEdgeLatch) { + this._peripheral._brightEdgeLatch = false; return true; + } + return false; + } + whenNear () { + if (!this._peripheral.isConnected()) return false; + if (this._peripheral._nearEdgeLatch) { + this._peripheral._nearEdgeLatch = false; return true; + } + return false; + } + setMicThreshold (args) { + return this._peripheral._setSensorThreshold('mic', parseInt(args.LEVEL, 10)); + } + setLightThreshold (args) { + return this._peripheral._setSensorThreshold('light', parseInt(args.LEVEL, 10)); + } + setTofThreshold (args) { + return this._peripheral._setSensorThreshold('tof', parseInt(args.LEVEL, 10)); + } + + capturePhoto () { + return this._peripheral.send('capture', {}).then(resp => { + if (!resp || resp.status !== 'ok' || !resp.url) return; + return fetch(resp.url) + .then(r => r.blob()) + .then(blob => new Promise(resolve => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + })) + .then(dataURI => { + this.runtime.emit('SPARK_CAMERA_FRAME', dataURI); + }) + .catch(err => log.warn('Spark camera capture failed:', err)); + }); + } + + // ── On-device AI (ai.classify) — Story 4.5. Each reporter returns the inference + // label; graceful fallback to a mock label + one-shot Thai toast on a board + // that can't run the primitive (FR28). aiConfidence reads the last result. ── + aiClassifyFace () { + return this._peripheral._classify('face', {}); + } + aiClassifyFaceId () { + // No arguments by design: the only question a project may ask is "which + // enrolled slot is this?". Anything that would let a block choose or name a + // person belongs in the teacher panel, not here. + return this._peripheral._classify('face_id', {}); + } + aiClassifyColor (args) { + const target = args.TARGET === 'any' ? null : args.TARGET; + return this._peripheral._classify('color', {target}); + } + aiClassifyMotion (args) { + // AC3: user-settable sensitivity, clamped 0..100 (default 50). + const raw = Math.round(Number(args.THRESHOLD)); + const threshold = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 50; + return this._peripheral._classify('motion', {threshold_pct: threshold}); + } + aiClassifyImuGesture (args) { + // AC3: 'any' → null (firmware recognizes any); else the selected gesture. + const gesture = args.GESTURE === 'any' ? null : args.GESTURE; + return this._peripheral._classify('imu_gesture', {gesture}); + } + aiConfidence () { + return this._peripheral._lastAi ? this._peripheral._lastAi.confidence : 0; + } + aiBbox (args) { + // AC4: read x/y/w/h of the last detection's bbox (0 when no bbox cached). + const bb = this._peripheral._lastAi && this._peripheral._lastAi.bbox; + if (!Array.isArray(bb)) return 0; + const idx = AI_BBOX_FIELDS.indexOf(args.FIELD); + return idx >= 0 && typeof bb[idx] === 'number' ? bb[idx] : 0; + } + + // ── QR card sensing (Epic 12 — Stories 12.4/12.6) ────────────────────── + setQrScan (args) { + // No board → no-op (no toast: "no scanner" is only meaningful on a + // connected board; mirrors the sensor reporters' isConnected guard). + if (!this._peripheral.isConnected()) return Promise.resolve(null); + // Story 12.3/FR45 — a board that announced its features but lacks qr_scan + // (e.g. camera-less substrate): one-shot Thai toast, no command sent. This + // is DISTINCT from the no-board case above (which is silent). A legacy + // board (capabilities unknown) falls through and relies on the response- + // based fallback below. + if (this._peripheral._lacksCapability('qr_scan')) { + if (!this._peripheral._stubWarningShown.has('qr')) { + this._peripheral._stubWarningShown.add('qr'); + this._peripheral._showStubToast('qr'); + } + return Promise.resolve(null); + } + const enable = args.STATE === 'on'; + // Arm/cancel the FR49 "nothing decoded" hint alongside the command. + if (enable) this._peripheral._startQrHintTimer(); + else this._peripheral._clearQrHintTimer(); + return this._peripheral.send('qr_scan_enable', {enable}).then(resp => { + // FR45 — board without a working scanner (no camera / camera_error / + // hw_not_present): one-shot Thai toast, mock behavior (the reporter + // returns '' and the HAT stays inert). One .scratch works on both. + if (enable && (!resp || resp.status !== 'ok')) { + this._peripheral._clearQrHintTimer(); + if (!this._peripheral._stubWarningShown.has('qr')) { + this._peripheral._stubWarningShown.add('qr'); + this._peripheral._showStubToast('qr'); + } + } + return resp; + }, () => { + // 12-7 review P9: the send rejected (no_transport / timeout / WS + // drop) — scanning never started, so the armed 5 s hint would fire + // for a scan that is not running. + this._peripheral._clearQrHintTimer(); + }); + } + whenScanned (args) { + if (!this._peripheral.isConnected()) return false; + // FR42 — exact match after whitespace trim. 12-7 review P22/P23: the + // latch clears per VM STEP, not per first consumer — every duplicate + // HAT polled within the same step sees the sighting; it expires when a + // LATER step polls. (runtime.currentMSecs is stamped once per step.) + const sightings = this._peripheral._qrSightings; + if (sightings.length === 0) return false; + const cur = this._peripheral._runtime.currentMSecs; + for (let i = sightings.length - 1; i >= 0; i--) { + if (sightings[i].stepMs !== null && sightings[i].stepMs !== cur) { + sightings.splice(i, 1); // consumed in an earlier step — expired + } + } + const target = String(args.TEXT).trim(); + const hit = sightings.find(sighting => sighting.text === target); + if (!hit) return false; + if (hit.stepMs === null) hit.stepMs = cur; // latch for the rest of this step + return true; + } + lastScannedText () { + // FR43 — synchronous cache like aiConfidence; '' before any scan. + return this._peripheral._lastScannedText; + } +} + +module.exports = Scratch3SparkBlocks; diff --git a/packages/scratch-vm/src/extensions/scratch3_spark/translations.js b/packages/scratch-vm/src/extensions/scratch3_spark/translations.js new file mode 100644 index 00000000000..3cd75034e8c --- /dev/null +++ b/packages/scratch-vm/src/extensions/scratch3_spark/translations.js @@ -0,0 +1,85 @@ +module.exports = { + th: { + 'spark.categoryName': 'สปาร์กี้', + 'spark.setLedColor': 'ตั้งสีไฟ LED [WHICH] เป็น [COLOR]', + 'spark.ledTarget.both': 'ทั้งคู่', + 'spark.ledTarget.led1': 'ดวงที่ 1', + 'spark.ledTarget.led2': 'ดวงที่ 2', + 'spark.setLedBrightness': 'ตั้งความสว่างไฟ LED [WHICH] เป็น [BRIGHTNESS]', + 'spark.whenButtonPressed': 'เมื่อกดปุ่ม [BTN]', + 'spark.isButtonPressed': 'กดปุ่ม [BTN] อยู่?', + 'spark.playTone': 'เล่นโน้ต [FREQ] Hz นาน [DUR] ms', + 'spark.stopBuzzer': 'หยุดเสียงบัซเซอร์', + 'spark.capturePhoto': 'ถ่ายภาพมาที่สเตจ', + 'spark.color.red': 'แดง', + 'spark.color.green': 'เขียว', + 'spark.color.amber': 'อำพัน', + 'spark.color.off': 'ปิด', + 'spark.imuAccelX': 'ความเร่งแกน X', + 'spark.imuAccelY': 'ความเร่งแกน Y', + 'spark.imuAccelZ': 'ความเร่งแกน Z', + 'spark.imuGyroX': 'ความเร็วเชิงมุมแกน X', + 'spark.imuGyroY': 'ความเร็วเชิงมุมแกน Y', + 'spark.imuGyroZ': 'ความเร็วเชิงมุมแกน Z', + 'spark.imuPitch': 'มุมก้มเงย', + 'spark.imuRoll': 'มุมเอียงข้าง', + 'spark.imuYaw': 'มุมหันซ้ายขวา', + 'spark.setImuFusion': 'ตั้งการรวมเซ็นเซอร์ IMU เป็น [ALGO]', + 'spark.fusionAlgo.none': 'ดิบ', + 'spark.fusionAlgo.complementary': 'นุ่มนวล', + 'spark.fusionAlgo.kalman': 'คาลมาน', + 'spark.fusionAlgo.madgwick': 'Madgwick', + 'spark.fusionAlgo.mahony': 'Mahony', + 'spark.whenShake': 'เมื่อเขย่า', + 'spark.setShakeSensitivity': 'ตั้งความไวการเขย่าเป็น [LEVEL]', + 'spark.shakeLevel.1': 'เบา', + 'spark.shakeLevel.2': 'ปานกลาง', + 'spark.shakeLevel.3': 'แรง', + // Stories 3.7/3.8/3.9 (SCP #4) — Mic/Light/TOF, live on a board with + // Spark-Sensors (mock 0 + one-shot warning toast otherwise). Previously + // these carried a "(stub) " prefix (Story 3.4 pending-HW indicator). + 'spark.micLevel': 'ระดับเสียง', + 'spark.lightLevel': 'ระดับแสง', + 'spark.tofDistance': 'ระยะใกล้สุด', + 'spark.whenLoud': 'เมื่อมีเสียงดัง', + 'spark.whenBright': 'เมื่อสว่างขึ้น', + 'spark.whenNear': 'เมื่อมีของใกล้', + 'spark.setMicThreshold': 'ตั้งความไวเสียงดังเป็น [LEVEL]', + 'spark.setLightThreshold': 'ตั้งความไวความสว่างเป็น [LEVEL]', + 'spark.setTofThreshold': 'ตั้งความไวระยะใกล้เป็น [LEVEL]', + 'spark.sensorLevel.1': 'สูง', + 'spark.sensorLevel.2': 'ปานกลาง', + 'spark.sensorLevel.3': 'ต่ำ', + // Story 4.5 — on-device AI (ai.classify) + 'spark.aiClassifyFace': 'ตรวจใบหน้า', + // Story 4.9: the '(~3 วินาที)' is part of the label on purpose. The block + // really does take about three seconds, and a child watching a sprite do + // nothing for that long will conclude the board is broken unless told. + 'spark.aiClassifyFaceId': 'จำใบหน้าได้ (~3 วินาที)', + 'spark.aiClassifyColor': 'ตรวจสี [TARGET]', + 'spark.aiClassifyMotion': 'ตรวจการเคลื่อนไหว (ความไว [THRESHOLD])', + 'spark.aiClassifyImuGesture': 'ตรวจท่าทาง [GESTURE]', + 'spark.aiConfidence': 'ความมั่นใจ AI', + 'spark.aiBbox': 'กรอบ AI [FIELD]', + 'spark.aiColor.any': 'สีเด่น', + 'spark.aiColor.red': 'แดง', + 'spark.aiColor.green': 'เขียว', + 'spark.aiColor.blue': 'น้ำเงิน', + 'spark.aiColor.yellow': 'เหลือง', + 'spark.aiGesture.any': 'ท่าใดก็ได้', + 'spark.aiGesture.shake': 'เขย่า', + 'spark.aiGesture.tilt': 'เอียง', + 'spark.aiGesture.flat': 'ราบ', + 'spark.aiGesture.flip': 'พลิก', + 'spark.aiBboxField.x': 'x', + 'spark.aiBboxField.y': 'y', + 'spark.aiBboxField.w': 'กว้าง', + 'spark.aiBboxField.h': 'สูง', + // Story 12.6 (Epic 12) — QR card sensing + 'spark.setQrScan': 'เปิดการสแกน QR [STATE]', + 'spark.qrScanState.on': 'เปิด', + 'spark.qrScanState.off': 'ปิด', + 'spark.whenScanned': 'เมื่อสแกนได้ [TEXT]', + 'spark.lastScannedText': 'ข้อความที่สแกนล่าสุด' + } +}; diff --git a/packages/scratch-vm/test/unit/extension_spark.js b/packages/scratch-vm/test/unit/extension_spark.js new file mode 100644 index 00000000000..ef1816fd8be --- /dev/null +++ b/packages/scratch-vm/test/unit/extension_spark.js @@ -0,0 +1,197 @@ +const test = require('tap').test; +const Scratch3SparkBlocks = require('../../src/extensions/scratch3_spark/index.js'); + +// Minimal runtime stub +const fakeRuntime = { + registerPeripheralExtension: () => {}, + emit: () => {}, + constructor: {PERIPHERAL_CONNECTED: 'PERIPHERAL_CONNECTED', PERIPHERAL_DISCONNECTED: 'PERIPHERAL_DISCONNECTED'} +}; + +const ext = new Scratch3SparkBlocks(fakeRuntime); + +test('extension has correct ID', t => { + t.equal(Scratch3SparkBlocks.EXTENSION_ID, 'Sparky'); + t.end(); +}); + +test('getInfo returns expected structure', t => { + const info = ext.getInfo(); + t.equal(info.id, 'Sparky'); + t.ok(info.name, 'has name'); + t.ok(Array.isArray(info.blocks), 'blocks is array'); + t.ok(info.menus, 'has menus'); + t.end(); +}); + +// Regression — the connection-modal hang was an id drift: getInfo().id / +// EXTENSION_ID / the peripheral registration id must all agree, since the GUI +// scans by the same id the peripheral registered under. +test('peripheral registers under the same id as getInfo().id', t => { + let registeredId = null; + const capturingRuntime = { + ...fakeRuntime, + registerPeripheralExtension: id => { + registeredId = id; + } + }; + const instance = new Scratch3SparkBlocks(capturingRuntime); + t.equal(registeredId, Scratch3SparkBlocks.EXTENSION_ID, 'registered under EXTENSION_ID'); + t.equal(instance.getInfo().id, Scratch3SparkBlocks.EXTENSION_ID, 'getInfo().id === EXTENSION_ID'); + t.end(); +}); + +test('getInfo contains LED blocks', t => { + const info = ext.getInfo(); + const opcodes = info.blocks.filter(b => typeof b === 'object').map(b => b.opcode); + t.ok(opcodes.includes('setLedColor'), 'has setLedColor'); + t.ok(opcodes.includes('setLedBrightness'), 'has setLedBrightness'); + t.end(); +}); + +test('getInfo contains Button blocks', t => { + const info = ext.getInfo(); + const opcodes = info.blocks.filter(b => typeof b === 'object').map(b => b.opcode); + t.ok(opcodes.includes('whenButtonPressed'), 'has whenButtonPressed'); + t.ok(opcodes.includes('isButtonPressed'), 'has isButtonPressed'); + t.end(); +}); + +test('getInfo contains Buzzer blocks', t => { + const info = ext.getInfo(); + const opcodes = info.blocks.filter(b => typeof b === 'object').map(b => b.opcode); + t.ok(opcodes.includes('playTone'), 'has playTone'); + t.ok(opcodes.includes('stopBuzzer'), 'has stopBuzzer'); + t.end(); +}); + +test('getInfo contains Camera blocks', t => { + const info = ext.getInfo(); + const opcodes = info.blocks.filter(b => typeof b === 'object').map(b => b.opcode); + t.ok(opcodes.includes('capturePhoto'), 'has capturePhoto'); + t.end(); +}); + +test('isButtonPressed returns false when not connected', t => { + t.equal(ext.isButtonPressed({BTN: 'A'}), false); + t.equal(ext.isButtonPressed({BTN: 'B'}), false); + t.end(); +}); + +test('whenButtonPressed returns false when not connected', t => { + t.equal(ext.whenButtonPressed({BTN: 'A'}), false); + t.equal(ext.whenButtonPressed({BTN: 'B'}), false); + t.end(); +}); + +// ─── Story 10.1: env-injected MIDDLEWARE_WS_URL ─────────────────────────────── +// +// WS_URL is module-evaluated at require time, so each case manipulates +// global.window, busts require.cache, re-requires the module, and verifies the +// URL passed to `new WebSocket()` via a stub. + +const sparkModulePath = require.resolve('../../src/extensions/scratch3_spark/index.js'); + +const reloadSparkExtension = () => { + delete require.cache[sparkModulePath]; + return require('../../src/extensions/scratch3_spark/index.js'); +}; + +class MockWebSocket { + constructor (url) { + MockWebSocket.lastUrl = url; + this.readyState = 0; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this.onclose = null; + } + close () {} +} + +const withSparkWebSocketEnv = (sparkEnv, fn) => { + const hadWindow = Object.prototype.hasOwnProperty.call(global, 'window'); + const hadWebSocket = Object.prototype.hasOwnProperty.call(global, 'WebSocket'); + const originalWindow = global.window; + const originalWebSocket = global.WebSocket; + if (sparkEnv === undefined) { + delete global.window; + } else { + global.window = {SPARK_ENV: sparkEnv}; + } + global.WebSocket = MockWebSocket; + MockWebSocket.lastUrl = null; + try { + const Mod = reloadSparkExtension(); + const instance = new Mod(fakeRuntime); + instance._peripheral.scan(); + return MockWebSocket.lastUrl; + } finally { + if (hadWindow) { + global.window = originalWindow; + } else { + delete global.window; + } + if (hadWebSocket) { + global.WebSocket = originalWebSocket; + } else { + delete global.WebSocket; + } + // Bust the cache so the original `ext` consumed by other tests is + // unaffected — and so the module re-evaluates on next require with a + // clean global state. + delete require.cache[sparkModulePath]; + } +}; + +test('Story 10.1 — WS_URL uses window.SPARK_ENV.MIDDLEWARE_WS_URL when set (K8s deployment path)', t => { + const url = withSparkWebSocketEnv({MIDDLEWARE_WS_URL: 'wss://staging.example/ws'}, () => {}); + t.equal(url, 'wss://staging.example/ws', 'WebSocket constructed with env-injected URL'); + t.end(); +}); + +test('Story 10.1 — WS_URL falls back to ws://localhost:8080 when window.SPARK_ENV is absent (Electron desktop path)', t => { + const url = withSparkWebSocketEnv(undefined, () => {}); + t.equal(url, 'ws://localhost:8080', 'WebSocket constructed with localhost fallback URL'); + t.end(); +}); + +// ── Story 2.8 — address the two physical LEDs independently ── +test('setLedColor per-LED index: both omits index, led1→0, led2→1', t => { + const sent = []; + const inst = new Scratch3SparkBlocks(fakeRuntime); + inst._peripheral.send = (cmd, data) => { + sent.push({cmd, data}); + return Promise.resolve({}); + }; + inst.setLedColor({WHICH: 'both', COLOR: 'red'}); + inst.setLedColor({WHICH: 'led1', COLOR: 'green'}); + inst.setLedColor({WHICH: 'led2', COLOR: 'amber'}); + t.equal(sent[0].cmd, 'led', 'cmd is led'); + t.same(sent[0].data, {pin: 2, r: 255, g: 0, b: 0}, 'both → no index (drives both, backward-compatible)'); + t.same(sent[1].data, {pin: 2, r: 0, g: 255, b: 0, index: 0}, 'led1 → index 0'); + t.same(sent[2].data, {pin: 2, r: 100, g: 255, b: 0, index: 1}, 'led2 → index 1 (amber = bench-tuned {100,255,0})'); + t.end(); +}); + +test('ledTargets menu + Thai translations are single-source (Story 2.8)', t => { + const info = ext.getInfo(); + t.same(info.menus.ledTargets.items.map(i => i.value), ['both', 'led1', 'led2'], 'menu values'); + const th = require('../../src/extensions/scratch3_spark/translations.js').th; + info.menus.ledTargets.items.forEach(i => t.ok(th[`spark.ledTarget.${i.value}`], `has spark.ledTarget.${i.value}`)); + t.end(); +}); + +test('setLedBrightness per-LED index (Story 2.8) + amber bench-tuning', t => { + const sent = []; + const inst = new Scratch3SparkBlocks(fakeRuntime); + inst._peripheral.send = (cmd, data) => { sent.push({cmd, data}); return Promise.resolve({}); }; + inst.setLedBrightness({WHICH: 'both', BRIGHTNESS: 200}); + inst.setLedBrightness({WHICH: 'led1', BRIGHTNESS: 50}); + inst.setLedBrightness({WHICH: 'led2', BRIGHTNESS: 255}); + t.equal(sent[0].cmd, 'pwm', 'cmd is pwm'); + t.same(sent[0].data, {pin: 2, val: 200}, 'both → no index'); + t.same(sent[1].data, {pin: 2, val: 50, index: 0}, 'led1 → index 0'); + t.same(sent[2].data, {pin: 2, val: 255, index: 1}, 'led2 → index 1'); + t.end(); +}); diff --git a/packages/scratch-vm/test/unit/extension_spark_ai.js b/packages/scratch-vm/test/unit/extension_spark_ai.js new file mode 100644 index 00000000000..8a30ff0061c --- /dev/null +++ b/packages/scratch-vm/test/unit/extension_spark_ai.js @@ -0,0 +1,159 @@ +// extension_spark_ai.js — Story 4.5 on-device AI (ai.classify) Scratch blocks. +// Drives the opcode handlers against a stubbed peripheral.send (no real socket), +// asserting the wire request shape, the returned label, aiConfidence/aiBbox, and the +// FR28 graceful-degradation fallback (mock label + one-shot SPARK_STUB_WARNING). +const test = require('tap').test; +const Scratch3SparkBlocks = require('../../src/extensions/scratch3_spark/index.js'); +const translations = require('../../src/extensions/scratch3_spark/translations.js'); + +// Build a Spark extension with a stubbed peripheral.send + a recording runtime. +const makeExt = (opts = {}) => { + const emits = []; + const runtime = { + registerPeripheralExtension: () => {}, + emit: (ev, payload) => emits.push({ev, payload}), + constructor: {PERIPHERAL_CONNECTED: 'c', PERIPHERAL_DISCONNECTED: 'd'} + }; + const ext = new Scratch3SparkBlocks(runtime); + const sent = []; + let responder = opts.responder || (() => ({status: 'ok', label: 'x', confidence: 0.5})); + ext._peripheral.isConnected = () => opts.connected !== false; + ext._peripheral.send = (cmd, data) => { + sent.push({cmd, data}); + return Promise.resolve(responder(cmd, data)); + }; + const setResponder = r => { + responder = r; + }; + return {ext, sent, emits, setResponder}; +}; + +test('getInfo exposes the 6 AI opcodes + AI menus', t => { + const {ext} = makeExt(); + const info = ext.getInfo(); + const opcodes = info.blocks.filter(b => typeof b === 'object').map(b => b.opcode); + ['aiClassifyFace', 'aiClassifyColor', 'aiClassifyMotion', 'aiClassifyImuGesture', 'aiConfidence', 'aiBbox'] + .forEach(op => t.ok(opcodes.includes(op), `has ${op}`)); + t.same(info.menus.aiColorTargets.items.map(i => i.value), ['any', 'red', 'green', 'blue', 'yellow']); + t.same(info.menus.aiGestures.items.map(i => i.value), ['any', 'shake', 'tilt', 'flat', 'flip']); + t.same(info.menus.aiBboxFields.items.map(i => i.value), ['x', 'y', 'w', 'h']); + t.end(); +}); + +test('single-source: every AI menu value has a Thai translation key', t => { + const {ext} = makeExt(); + const info = ext.getInfo(); + const th = translations.th; + info.menus.aiColorTargets.items.forEach(i => t.ok(th[`spark.aiColor.${i.value}`], `aiColor.${i.value}`)); + info.menus.aiGestures.items.forEach(i => t.ok(th[`spark.aiGesture.${i.value}`], `aiGesture.${i.value}`)); + info.menus.aiBboxFields.items.forEach(i => t.ok(th[`spark.aiBboxField.${i.value}`], `aiBboxField.${i.value}`)); + t.end(); +}); + +test('aiClassifyColor sends {primitive:color, params:{target}} and returns the label + confidence', async t => { + const {ext, sent} = makeExt({ + responder: () => ({status: 'ok', primitive: 'color', label: 'red', confidence: 0.92, bbox: [1, 2, 3, 4]}) + }); + const label = await ext.aiClassifyColor({TARGET: 'red'}); + t.equal(label, 'red'); + t.equal(sent[0].cmd, 'ai.classify'); + t.same(sent[0].data, {primitive: 'color', params: {target: 'red'}}); + t.equal(ext.aiConfidence(), 0.92, 'aiConfidence reads the last result'); + t.end(); +}); + +test('aiClassifyColor TARGET=any → params.target null (dominant)', async t => { + const {ext, sent} = makeExt({responder: () => ({status: 'ok', label: 'green', confidence: 0.5})}); + await ext.aiClassifyColor({TARGET: 'any'}); + t.same(sent[0].data, {primitive: 'color', params: {target: null}}); + t.end(); +}); + +test('aiClassifyFace returns face_count label', async t => { + const {ext, sent} = makeExt({responder: () => ({status: 'ok', label: 'face_count_2', confidence: 0.8})}); + const label = await ext.aiClassifyFace(); + t.equal(label, 'face_count_2'); + t.same(sent[0].data, {primitive: 'face', params: {}}); + t.end(); +}); + +test('AC3: aiClassifyMotion THRESHOLD arg is passed + clamped 0..100 (default 50)', async t => { + const {ext, sent} = makeExt({responder: () => ({status: 'ok', label: 'still', confidence: 0})}); + await ext.aiClassifyMotion({THRESHOLD: 70}); + t.same(sent[0].data, {primitive: 'motion', params: {threshold_pct: 70}}, 'passes the arg'); + await ext.aiClassifyMotion({THRESHOLD: 150}); + t.equal(sent[1].data.params.threshold_pct, 100, 'clamps high'); + await ext.aiClassifyMotion({THRESHOLD: -5}); + t.equal(sent[2].data.params.threshold_pct, 0, 'clamps low'); + await ext.aiClassifyMotion({THRESHOLD: undefined}); + t.equal(sent[3].data.params.threshold_pct, 50, 'defaults to 50 on non-numeric'); + t.end(); +}); + +test('AC3: aiClassifyImuGesture GESTURE arg → params.gesture (any→null)', async t => { + const {ext, sent} = makeExt({responder: () => ({status: 'ok', label: 'shake', confidence: 0.7})}); + await ext.aiClassifyImuGesture({GESTURE: 'shake'}); + t.same(sent[0].data, {primitive: 'imu_gesture', params: {gesture: 'shake'}}); + await ext.aiClassifyImuGesture({GESTURE: 'any'}); + t.same(sent[1].data, {primitive: 'imu_gesture', params: {gesture: null}}, 'any → null'); + t.end(); +}); + +test('AC4: aiBbox reads x/y/w/h of the last detection; 0 when no bbox', async t => { + const {ext} = makeExt({ + responder: () => ({status: 'ok', label: 'face_count_1', confidence: 0.9, bbox: [10, 20, 30, 40]}) + }); + await ext.aiClassifyFace(); + t.equal(ext.aiBbox({FIELD: 'x'}), 10); + t.equal(ext.aiBbox({FIELD: 'y'}), 20); + t.equal(ext.aiBbox({FIELD: 'w'}), 30); + t.equal(ext.aiBbox({FIELD: 'h'}), 40); + // no-bbox response → 0 + const {ext: ext2} = makeExt({responder: () => ({status: 'ok', label: 'face_count_0', confidence: 0})}); + await ext2.aiClassifyFace(); + t.equal(ext2.aiBbox({FIELD: 'x'}), 0, 'no bbox → 0'); + t.end(); +}); + +test('companion cache is coherent with the LAST block run (degrade does not leave stale confidence/bbox)', async t => { + const {ext, setResponder} = makeExt({ + responder: () => ({status: 'ok', label: 'face_count_1', confidence: 0.8, bbox: [1, 2, 3, 4]}) + }); + await ext.aiClassifyFace(); + t.equal(ext.aiConfidence(), 0.8, 'good result cached'); + t.equal(ext.aiBbox({FIELD: 'x'}), 1); + // now a degraded call (error) must reset the companions to the mock state + setResponder(() => ({status: 'error', error_code: 'hw_not_present'})); + const label = await ext.aiClassifyColor({TARGET: 'red'}); + t.equal(label, 'not_found', 'color degrades to mock'); + t.equal(ext.aiConfidence(), 0, 'confidence NOT stale (0 for the degraded call)'); + t.equal(ext.aiBbox({FIELD: 'x'}), 0, 'bbox NOT stale'); + t.end(); +}); + +test('FR28: error response → mock label + ONE SPARK_STUB_WARNING per session', async t => { + const {ext, emits} = makeExt({responder: () => ({status: 'error', error_code: 'model_load_failed'})}); + const face = await ext.aiClassifyFace(); + const color = await ext.aiClassifyColor({TARGET: 'red'}); + t.equal(face, 'face_count_0', 'face mock'); + t.equal(color, 'not_found', 'color mock'); + const warnings = emits.filter(e => e.ev === 'SPARK_STUB_WARNING'); + t.equal(warnings.length, 1, 'toast shown once (family ai), not per-call'); + t.equal(warnings[0].payload.family, 'ai'); + t.end(); +}); + +test('timeout (null response) → mock label, no throw', async t => { + const {ext} = makeExt({responder: () => null}); + const label = await ext.aiClassifyMotion({THRESHOLD: 50}); + t.equal(label, 'still'); + t.end(); +}); + +test('not connected → mock label, send NOT called', async t => { + const {ext, sent} = makeExt({connected: false}); + const label = await ext.aiClassifyFace(); + t.equal(label, 'face_count_0'); + t.equal(sent.length, 0, 'no send when disconnected'); + t.end(); +}); diff --git a/packages/scratch-vm/test/unit/extension_spark_colors.js b/packages/scratch-vm/test/unit/extension_spark_colors.js new file mode 100644 index 00000000000..051db0cc33f --- /dev/null +++ b/packages/scratch-vm/test/unit/extension_spark_colors.js @@ -0,0 +1,23 @@ +const test = require('tap').test; +const fs = require('fs'); +const path = require('path'); + +const base = path.resolve(__dirname, '../../src/extensions/scratch3_spark'); +const src = fs.readFileSync(path.join(base, 'index.js'), 'utf8'); +const th = fs.readFileSync(path.join(base, 'translations.js'), 'utf8'); + +test('LED_COLOR_MAP is the single source: every color has a Thai translation', t => { + const m = src.match(/const LED_COLOR_MAP\s*=\s*\{([\s\S]*?)\};/); + t.ok(m, 'LED_COLOR_MAP literal found'); + const names = [...m[1].matchAll(/(\w+)\s*:\s*\{\s*r:/g)].map(x => x[1]); + t.ok(names.length >= 2, `parsed color names: ${names.join(',')}`); + const missing = names.filter(n => !th.includes(`spark.color.${n}`)); + t.same(missing, [], `every LED_COLOR_MAP key has spark.color. (missing: ${missing.join(',') || 'none'})`); + t.end(); +}); + +test('color menu derives from the map (no hardcoded items array)', t => { + t.match(src, /items:\s*ledColorMenuItems\(\)/, 'ledColors menu uses ledColorMenuItems()'); + t.notMatch(src, /\bconst LedColor\b/, 'the LedColor enum is gone (single source)'); + t.end(); +}); diff --git a/packages/scratch-vm/test/unit/extension_spark_facerec.js b/packages/scratch-vm/test/unit/extension_spark_facerec.js new file mode 100644 index 00000000000..3f6195e829b --- /dev/null +++ b/packages/scratch-vm/test/unit/extension_spark_facerec.js @@ -0,0 +1,125 @@ +// extension_spark_facerec.js — Story 4.9 (FR58) face-recognition block. +// +// Three properties are worth testing here and they are not equally obvious: +// 1. the block asks the right question and returns the opaque slot label; +// 2. it degrades to person_none rather than erroring (FR28) — a project written +// against face_id must still run on a board without the capability; +// 3. it is sent with a LONGER timeout than the other AI blocks. That is not a +// style choice: recognition measured ~2.75 s on board v2 (bench 2026-08-06, +// worst sample 2,832 ms) against the shared 3 s default, so the default would +// have expired intermittently and shown a mock label as if the board had +// answered. A regression here is silent and looks like flaky hardware, which +// is exactly the kind of bug a test should be holding down. +const test = require('tap').test; +const Scratch3SparkBlocks = require('../../src/extensions/scratch3_spark/index.js'); +const translations = require('../../src/extensions/scratch3_spark/translations.js'); + +// Records the timeout argument too — the stub in extension_spark_ai.js ignores it. +const makeExt = (opts = {}) => { + const emits = []; + const runtime = { + registerPeripheralExtension: () => {}, + emit: (ev, payload) => emits.push({ev, payload}), + constructor: {PERIPHERAL_CONNECTED: 'c', PERIPHERAL_DISCONNECTED: 'd'} + }; + const ext = new Scratch3SparkBlocks(runtime); + const sent = []; + const responder = opts.responder || (() => ({status: 'ok', label: 'person_1', confidence: 0.9})); + ext._peripheral.isConnected = () => opts.connected !== false; + ext._peripheral.send = (cmd, data, timeoutMs) => { + sent.push({cmd, data, timeoutMs}); + return Promise.resolve(responder(cmd, data)); + }; + return {ext, sent, emits}; +}; + +test('getInfo exposes aiClassifyFaceId and it takes no arguments', t => { + const {ext} = makeExt(); + const block = ext.getInfo().blocks.filter(b => typeof b === 'object') + .find(b => b.opcode === 'aiClassifyFaceId'); + t.ok(block, 'block is registered'); + // No arguments by design: a block that could name or select a person would put + // personal data inside the .sb3 file children share. + t.notOk(block.arguments, 'no arguments'); + t.end(); +}); + +test('the Thai label warns about the ~3 s wait', t => { + const th = translations.th['spark.aiClassifyFaceId']; + t.ok(th, 'has a Thai label'); + t.match(th, /3/, 'the label mentions the duration — a 3 s block reads as a hang without it'); + t.end(); +}); + +test('sends {primitive:face_id, params:{}} and returns the slot label', async t => { + const {ext, sent} = makeExt({ + responder: () => ({status: 'ok', primitive: 'face_id', label: 'person_2', confidence: 0.88}) + }); + const label = await ext.aiClassifyFaceId(); + t.equal(label, 'person_2'); + t.equal(sent[0].cmd, 'ai.classify'); + t.same(sent[0].data, {primitive: 'face_id', params: {}}); + t.equal(ext.aiConfidence(), 0.88, 'aiConfidence reads the recognition result'); + t.end(); +}); + +test('face_id is sent with a longer timeout than the 3 s default', async t => { + const {ext, sent} = makeExt(); + await ext.aiClassifyFaceId(); + t.ok(sent[0].timeoutMs > 3000, + `timeout ${sent[0].timeoutMs} ms must exceed the 3 s default — recognition takes ~2.75 s`); + // Must also clear the agreed p95 budget (3,500 ms, Story 4.9 AC3) with margin, + // and stay above the middleware's 6 s router timeout for this primitive so the + // block receives a real error instead of giving up first. + t.ok(sent[0].timeoutMs >= 6000, 'at least the middleware router timeout for face_id'); + t.end(); +}); + +test('the other AI blocks keep the 3 s default', async t => { + const {ext, sent} = makeExt({responder: () => ({status: 'ok', label: 'face_count_1', confidence: 0.7})}); + await ext.aiClassifyFace(); + t.ok(sent[0].timeoutMs === undefined || sent[0].timeoutMs === 3000, + 'detection is unchanged by Story 4.9'); + t.end(); +}); + +test('FR28: no camera / capability absent → person_none plus a one-shot toast', async t => { + const {ext, emits} = makeExt({ + responder: () => ({status: 'error', error_code: 'hw_not_present'}) + }); + const label = await ext.aiClassifyFaceId(); + t.equal(label, 'person_none', + 'degrades to "I recognise nobody" — the same answer as an empty enrolment store'); + t.equal(emits.filter(e => e.ev === 'SPARK_STUB_WARNING').length, 1, 'one toast'); + t.end(); +}); + +test('FR28: a firmware without the command (invalid_cmd) degrades the same way', async t => { + const {ext} = makeExt({responder: () => ({status: 'error', error_code: 'invalid_cmd'})}); + t.equal(await ext.aiClassifyFaceId(), 'person_none'); + t.end(); +}); + +test('FR28: a board that never answers degrades instead of hanging the script', async t => { + const {ext} = makeExt({responder: () => null}); + t.equal(await ext.aiClassifyFaceId(), 'person_none'); + t.end(); +}); + +test('disconnected board returns person_none without touching the wire', async t => { + const {ext, sent} = makeExt({connected: false}); + t.equal(await ext.aiClassifyFaceId(), 'person_none'); + t.equal(sent.length, 0, 'nothing sent'); + t.end(); +}); + +test('no block exposes enrolment (AC6 — the extension side of the rule)', t => { + const {ext} = makeExt(); + const opcodes = ext.getInfo().blocks.filter(b => typeof b === 'object').map(b => b.opcode); + // The middleware refuses faceEnroll/faceForget on this channel regardless; this + // asserts the extension never even offers the affordance. + opcodes.forEach(op => { + t.notMatch(op, /enrol|enroll|forget/i, `${op} is not an enrolment affordance`); + }); + t.end(); +}); diff --git a/packages/scratch-vm/test/unit/extension_spark_qr.js b/packages/scratch-vm/test/unit/extension_spark_qr.js new file mode 100644 index 00000000000..cf91646bc93 --- /dev/null +++ b/packages/scratch-vm/test/unit/extension_spark_qr.js @@ -0,0 +1,172 @@ +// extension_spark_qr.js — Story 12.6 QR card-sensing Scratch blocks. +// Drives the opcode handlers + the qr_seen event path against a stubbed +// peripheral.send (no real socket): the qr_scan_enable wire shape, the +// whenScanned per-STEP edge-latch (exact-after-trim; every duplicate HAT in +// the same VM step fires — 12-7 review P22/P23), the lastScannedText reporter +// RAW cache/reset (P20), and the FR45 one-shot fallback toast. +const test = require('tap').test; +const Scratch3SparkBlocks = require('../../src/extensions/scratch3_spark/index.js'); +const translations = require('../../src/extensions/scratch3_spark/translations.js'); + +const makeExt = (opts = {}) => { + const emits = []; + const runtime = { + registerPeripheralExtension: () => {}, + emit: (ev, payload) => emits.push({ev, payload}), + constructor: {PERIPHERAL_CONNECTED: 'c', PERIPHERAL_DISCONNECTED: 'd'}, + // the real Runtime stamps currentMSecs once per _step; whenScanned's + // per-step latch (12-7 P22) keys off it. Tests advance it manually. + currentMSecs: 1000 + }; + const ext = new Scratch3SparkBlocks(runtime); + const sent = []; + let responder = opts.responder || (() => ({status: 'ok'})); + ext._peripheral.isConnected = () => opts.connected !== false; + ext._peripheral.send = (cmd, data) => { + sent.push({cmd, data}); + return Promise.resolve(responder(cmd, data)); + }; + return {ext, sent, emits}; +}; + +test('getInfo exposes the QR opcodes + qrScanState menu', t => { + const {ext} = makeExt(); + const info = ext.getInfo(); + const opcodes = info.blocks.filter(b => typeof b === 'object').map(b => b.opcode); + ['setQrScan', 'whenScanned', 'lastScannedText'].forEach(op => t.ok(opcodes.includes(op), `has ${op}`)); + t.same(info.menus.qrScanState.items.map(i => i.value), ['on', 'off']); + t.end(); +}); + +test('single-source: every QR label + menu value has a Thai translation key', t => { + const {ext} = makeExt(); + const info = ext.getInfo(); + const th = translations.th; + ['spark.setQrScan', 'spark.whenScanned', 'spark.lastScannedText'].forEach(k => t.ok(th[k], k)); + info.menus.qrScanState.items.forEach(i => t.ok(th[`spark.qrScanState.${i.value}`], `qrScanState.${i.value}`)); + t.end(); +}); + +test('setQrScan on/off sends qr_scan_enable {enable}', async t => { + const {ext, sent} = makeExt(); + t.teardown(() => ext._peripheral._clearQrHintTimer()); + await ext.setQrScan({STATE: 'on'}); + t.equal(sent[0].cmd, 'qr_scan_enable'); + t.same(sent[0].data, {enable: true}); + await ext.setQrScan({STATE: 'off'}); + t.same(sent[1].data, {enable: false}); + t.end(); +}); + +test('lastScannedText is empty before any scan; caches the RAW payload after qr_seen (12-7 P20)', t => { + const {ext} = makeExt(); + t.equal(ext.lastScannedText(), '', 'empty before first scan'); + ext._peripheral._onEvent({event: 'qr_seen', text: ' เสือ '}); + t.equal(ext.lastScannedText(), ' เสือ ', 'raw cache — FR43 gives the student the text as scanned'); + t.end(); +}); + +test('whitespace-only qr_seen is junk: no cache, no latch (12-7 P12)', t => { + const {ext} = makeExt(); + ext._peripheral._onEvent({event: 'qr_seen', text: ' '}); + t.equal(ext.lastScannedText(), '', 'reporter unchanged'); + t.equal(ext.whenScanned({TEXT: ''}), false, 'blank-target HAT does not fire on junk'); + t.end(); +}); + +test('whenScanned: per-step latch — duplicates fire in the SAME step, expires on a later step (12-7 P22)', t => { + const {ext} = makeExt(); + const rt = ext._peripheral._runtime; + ext._peripheral._onEvent({event: 'qr_seen', text: 'เสือ'}); + rt.currentMSecs = 2000; // step N + t.equal(ext.whenScanned({TEXT: ' เสือ '}), true, 'matches after trim'); + t.equal(ext.whenScanned({TEXT: 'เสือ'}), true, 'duplicate HAT in the SAME step also fires'); + rt.currentMSecs = 2033; // step N+1 + t.equal(ext.whenScanned({TEXT: 'เสือ'}), false, 'expired on the next step — one sighting, one step'); + // a different card + ext._peripheral._onEvent({event: 'qr_seen', text: 'ช้าง'}); + rt.currentMSecs = 2066; + t.equal(ext.whenScanned({TEXT: 'เสือ'}), false, 'other HAT does not fire for a different card'); + t.equal(ext.whenScanned({TEXT: 'ช้าง'}), true, 'the matching HAT fires'); + t.end(); +}); + +test('two qr_seen inside one VM tick: BOTH sightings reach their HATs (12-7 P23)', t => { + const {ext} = makeExt(); + const rt = ext._peripheral._runtime; + ext._peripheral._onEvent({event: 'qr_seen', text: 'เสือ'}); + ext._peripheral._onEvent({event: 'qr_seen', text: 'ช้าง'}); + rt.currentMSecs = 3000; + t.equal(ext.whenScanned({TEXT: 'เสือ'}), true, 'first sighting not overwritten'); + t.equal(ext.whenScanned({TEXT: 'ช้าง'}), true, 'second sighting fires too'); + rt.currentMSecs = 3033; + t.equal(ext.whenScanned({TEXT: 'เสือ'}), false, 'both expired next step'); + t.equal(ext.whenScanned({TEXT: 'ช้าง'}), false, 'both expired next step'); + t.end(); +}); + +test('whenScanned returns false when disconnected', t => { + const {ext} = makeExt({connected: false}); + ext._peripheral._onEvent({event: 'qr_seen', text: 'เสือ'}); + t.equal(ext.whenScanned({TEXT: 'เสือ'}), false); + t.end(); +}); + +test('disconnect resets the reporter + clears the sighting latch (FR43)', t => { + const {ext} = makeExt(); + ext._peripheral._onEvent({event: 'qr_seen', text: 'เสือ'}); + t.equal(ext.lastScannedText(), 'เสือ'); + ext._peripheral._resetEdgeLatches(); + t.equal(ext.lastScannedText(), '', 'reporter reset on disconnect'); + t.equal(ext.whenScanned({TEXT: 'เสือ'}), false, 'stale sighting cannot fire after reset'); + t.end(); +}); + +test('FR45 fallback: a scanner-less board shows the one-shot QR toast', async t => { + const {ext, emits} = makeExt({responder: () => ({status: 'error', error_code: 'camera_error'})}); + await ext.setQrScan({STATE: 'on'}); + await ext.setQrScan({STATE: 'on'}); // second time must NOT re-toast + const warnings = emits.filter(e => e.ev === 'SPARK_STUB_WARNING' && e.payload.family === 'qr'); + t.equal(warnings.length, 1, 'toast shown exactly once per session'); + t.match(warnings[0].payload.text, /QR/, 'the QR-specific copy'); + t.end(); +}); + +test('FR45: disconnected board sends nothing and the reporter stays mock-empty', async t => { + const {ext, sent} = makeExt({connected: false}); + const r = await ext.setQrScan({STATE: 'on'}); + t.equal(r, null, 'send short-circuits to null when disconnected'); + t.equal(sent.length, 0, 'no command written'); + t.equal(ext.lastScannedText(), '', 'reporter mock-empty'); + t.end(); +}); + +// ── Story 12.3 capability handshake gating ────────────────────────────────── +test('12.3: a board that announced features but lacks qr_scan → toast, no send', async t => { + const {ext, sent, emits} = makeExt(); + ext._peripheral._capabilities = new Set(['gpio', 'imu']); // announced, no qr_scan + const r = await ext.setQrScan({STATE: 'on'}); + t.equal(r, null, 'gated before send'); + t.equal(sent.length, 0, 'no qr_scan_enable written'); + const warnings = emits.filter(e => e.ev === 'SPARK_STUB_WARNING' && e.payload.family === 'qr'); + t.equal(warnings.length, 1, 'one-shot QR toast'); + t.end(); +}); + +test('12.3: a board WITH qr_scan capability sends normally', async t => { + const {ext, sent} = makeExt(); + t.teardown(() => ext._peripheral._clearQrHintTimer()); + ext._peripheral._capabilities = new Set(['gpio', 'camera', 'qr_scan']); + await ext.setQrScan({STATE: 'on'}); + t.equal(sent[0].cmd, 'qr_scan_enable'); + t.end(); +}); + +test('12.3: a legacy board (capabilities unknown) still sends — backward compatible', async t => { + const {ext, sent} = makeExt(); + t.teardown(() => ext._peripheral._clearQrHintTimer()); + ext._peripheral._capabilities = null; // pre-handshake firmware + await ext.setQrScan({STATE: 'on'}); + t.equal(sent[0].cmd, 'qr_scan_enable', 'legacy board: block still works'); + t.end(); +}); diff --git a/packages/scratch-vm/webpack.config.js b/packages/scratch-vm/webpack.config.js index a9e6973851b..d8a21d0cd19 100644 --- a/packages/scratch-vm/webpack.config.js +++ b/packages/scratch-vm/webpack.config.js @@ -51,7 +51,7 @@ const playgroundBuilder = webBuilder .clone() .merge({ devServer: { - contentBase: false, + static: false, host: '0.0.0.0', port: process.env.PORT || 8073 }, diff --git a/scripts/fetch-firmware.sh b/scripts/fetch-firmware.sh new file mode 100755 index 00000000000..7a922c5f2b6 --- /dev/null +++ b/scripts/fetch-firmware.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Story 7.6 — resolve the highest published firmware semver from the GitLab Generic +# Package Registry, download .bin/.sha256/.meta.json, verify integrity (sha256, fail-closed), +# and emit firmware/manifest.json with an absolute URL. The meta.json already carries +# `signature` (base64 raw Ed25519, from the firmware CI) → it passes through into the manifest, +# which the middleware firmwareVerifier requires (Story 9.2). No firmware published yet ⇒ +# hard-fail (the image must never ship a /firmware/ that 404s). +set -euo pipefail + +: "${GITLAB_API_BASE:?}"; : "${GITLAB_FW_PROJECT_ID:?}"; : "${GITLAB_FW_READ_TOKEN:?}" +PUBLIC_BASE="${PUBLIC_FW_BASE:-https://sparky-uat.warut.me/firmware}" +OUT=firmware +H=(-H "PRIVATE-TOKEN: ${GITLAB_FW_READ_TOKEN}") +API="${GITLAB_API_BASE}/projects/${GITLAB_FW_PROJECT_ID}" + +mkdir -p "$OUT" +# Highest semver among generic 'firmware' package versions. +VER=$(curl --fail -s "${H[@]}" \ + "${API}/packages?package_type=generic&package_name=firmware&per_page=100" \ + | jq -r '.[].version' | sort -V | tail -n1) +test -n "$VER" || { echo "FATAL: no firmware package published"; exit 1; } +echo "resolved firmware $VER" + +PKG="${API}/packages/generic/firmware/${VER}" +curl --fail -s "${H[@]}" "${PKG}/spark_fw_${VER}.bin" -o "$OUT/spark_fw_${VER}.bin" +curl --fail -s "${H[@]}" "${PKG}/spark_fw_${VER}.sha256" -o "$OUT/spark_fw_${VER}.sha256" +curl --fail -s "${H[@]}" "${PKG}/spark_fw_${VER}.meta.json" -o "$OUT/spark_fw_${VER}.meta.json" + +# Integrity gate (fail-closed): published sha256 must match the bytes. +ACT=$(sha256sum "$OUT/spark_fw_${VER}.bin" | awk '{print $1}') +EXP=$(tr -d ' \n' < "$OUT/spark_fw_${VER}.sha256") +test "$ACT" = "$EXP" || { echo "FATAL: sha256 mismatch ($ACT != $EXP)"; exit 1; } + +# Generate the served manifest: meta (incl. sha256 + signature) + absolute url. +jq --arg url "${PUBLIC_BASE}/spark_fw_${VER}.bin" '. + {url:$url}' \ + "$OUT/spark_fw_${VER}.meta.json" > "$OUT/manifest.json" +rm -f "$OUT/spark_fw_${VER}.sha256" "$OUT/spark_fw_${VER}.meta.json" +echo "manifest.json (signature elided):"; jq 'del(.signature)' "$OUT/manifest.json"