diff --git a/.gitattributes b/.gitattributes index 281c932d..cfb446e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,6 @@ *.bin binary *.apk binary *.zip binary +# Immutable donor snapshot: retain its hash-verified original trailing whitespace. +# Pakar adaptations and all other source remain subject to whitespace checks. +third_party/portable-shell/upstream/** whitespace=-blank-at-eol,-blank-at-eof diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ac2acd9..47aa0818 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,12 +59,14 @@ jobs: shell: pwsh run: | $taskPins = Get-Content tools/firmware-dependencies.json -Raw | ConvertFrom-Json - $taskJson = @($taskPins.libraries | Where-Object name -eq 'ArduinoJson') - if ($taskJson.Count -ne 1 -or $taskJson[0].version -notmatch '^\d+\.\d+\.\d+$') { throw 'Missing exact ArduinoJson pin' } arduino-cli lib update-index if ($LASTEXITCODE -ne 0) { throw 'Host-test library index failed' } - arduino-cli lib install ('ArduinoJson@' + $taskJson[0].version) - if ($LASTEXITCODE -ne 0) { throw 'Pinned host-test headers failed' } + foreach ($taskName in @('ArduinoJson','LibSSH-ESP32')) { + $taskLibrary = @($taskPins.libraries | Where-Object name -eq $taskName) + if ($taskLibrary.Count -ne 1 -or $taskLibrary[0].version -notmatch '^\d+\.\d+\.\d+$') { throw "Missing exact $taskName pin" } + arduino-cli lib install ($taskName + '@' + $taskLibrary[0].version) + if ($LASTEXITCODE -ne 0) { throw 'Pinned host-test headers failed' } + } - run: node tools/ci-checks.mjs quality - run: node tests/led_backend_integration_test.mjs - run: node tests/web_led_backend_test.mjs @@ -105,6 +107,9 @@ jobs: needs: quality runs-on: ubuntu-24.04 timeout-minutes: 60 + permissions: + contents: read + actions: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -124,6 +129,13 @@ jobs: bash tools/test-pc-flags.sh # ESP8266 source/tooling is retained, but its full image currently # exceeds physical RAM/flash budgets and is not a release target. + # Board-owned key games need both ends compiled, not only host fixtures. + # Subsequent table builds restore the canonical release artifacts. + pwsh -NoProfile -File tools/build-firmware.ps1 -Target avr -GameMode first-wins -LinkMode buffered -AvrRadio -Jobs 2 + pwsh -NoProfile -File tools/build-firmware.ps1 -Target esp32s3 -GameMode first-wins -LinkMode buffered -S3FlashMB 16 -S3Psram opi -S3LedGpio -S3LedRmtDma -S3Bluetooth disabled -Jobs 2 + # Optional encrypted transport + transactional SCP/SFTP and opt-in FTP; later builds + # restore the default SSH-excluded artifact in the same SDK cache. + pwsh -NoProfile -File tools/build-firmware.ps1 -Target esp32s3 -GameMode first-wins -LinkMode buffered -S3FlashMB 16 -S3Psram opi -S3LedGpio -S3LedRmtDma -S3Bluetooth disabled -S3Ssh -S3Scp -S3Sftp -S3Ftp -Jobs 2 bash build.sh polling avr bash build.sh busy-wire avr bash build.sh buffered avr @@ -137,6 +149,34 @@ jobs: # builds a no-matrix image, which must not be the release artifact. bash build.sh buffered esp32s3 --s3-flash-mb 16 --s3-psram opi --s3-led-gpio --s3-led-rmt-dma node tools/firmware-report.mjs --require --write + - name: Previous successful main resource baseline + env: + GH_TOKEN: ${{ github.token }} + run: | + if run=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow build.yml --branch main --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty'); then + if [[ -n "$run" ]]; then + if ! gh run download "$run" --repo "$GITHUB_REPOSITORY" --name PakarController-resources --dir build/resource-baseline; then + echo 'No retained resource baseline available; absolute budgets still enforced.' + fi + fi + else + echo 'Baseline lookup unavailable; absolute budgets still enforced.' + fi + - name: Resource budgets and historical comparison + run: | + args=() + if [[ -f build/resource-baseline/resource-report.json ]]; then + args+=(--baseline build/resource-baseline/resource-report.json) + fi + node tools/resource-report.mjs --write "${args[@]}" + - name: Publish resource report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: PakarController-resources + path: build/resource-report.json + retention-days: 90 + if-no-files-found: warn - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: PakarController-firmware @@ -288,6 +328,9 @@ jobs: needs: [lint, tooling, quality, firmware, windows-desktop, android-preview, release] runs-on: ubuntu-24.04 timeout-minutes: 5 + permissions: + contents: read + actions: read steps: - env: LINT: ${{ needs.lint.result }} @@ -297,6 +340,7 @@ jobs: WINDOWS: ${{ needs.windows-desktop.result }} ANDROID: ${{ needs.android-preview.result }} RELEASE: ${{ needs.release.result }} + GH_TOKEN: ${{ github.token }} run: | { echo '# PakarController build verification'; echo @@ -310,5 +354,17 @@ jobs: echo "| Draft release | $RELEASE |" echo; echo '> CI does not prove physical operation or deployment.' } >> "$GITHUB_STEP_SUMMARY" + # Adapt Pealayer's real run-artifact summary; enumerate every page. + { + echo; echo '## Run artifacts'; echo + echo '| Artifact | Bytes | Expires |'; echo '|---|---:|---|' + } >> "$GITHUB_STEP_SUMMARY" + if artifact_rows=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | "| \(.name) | \(.size_in_bytes) | \(.expires_at) |"'); then + if [[ -n "$artifact_rows" ]]; then printf '%s\n' "$artifact_rows" >> "$GITHUB_STEP_SUMMARY"; else + echo '| None produced by this run | — | — |' >> "$GITHUB_STEP_SUMMARY" + fi + else + echo '| Inventory unavailable; inspect this run | — | — |' >> "$GITHUB_STEP_SUMMARY" + fi [[ "$LINT" == success && "$TOOLING" == success && "$QUALITY" == success && "$FIRMWARE" == success && "$WINDOWS" == success && "$ANDROID" == success ]] [[ "$RELEASE" == success || "$RELEASE" == skipped ]] diff --git a/AGENTS.md b/AGENTS.md index 09bdae94..110dcafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - FIRST on every task, missing capability or tool/build failure: read docs/REUSE-PREFLIGHT.md and run tools/consult-reuse.ps1 -Concern . Inspect every matching mapping/pending action, then read actual donor sources/dependencies before replacement code or declaring unavailable. Reuse Pealayer/PCController/PU implementations and tests, not just their style. Update the existing hash-bound file ledger and GitHub issue with every useful omission; screened/unread files are never accepted exclusions. Add an index route when a failure has no match. Build/stage/capture gates are mandatory and must not be bypassed by direct commands. - Preserve existing user/concurrent changes. Donor checkouts remain read-only unless the user requests changes there. +- Native hover/press regression rule: every action, toggle (including Arrange boxes), selectable row and menu must retain the same allocated rectangle, padding, font, text origin, border width/radius and sibling positions across idle/hover/press/focus/selected/disabled states. Hover changes color only; paint custom strokes Inside. Do not add a hardcoded Button stroke after egui has calculated its frame margins, or use stock selectable labels without verifying their geometry. Reuse the existing control-density/dialog helpers and Pealayer 5b3d5c1 fix; test actual pointer transitions in both themes, densities and fractional DPI (response rectangles AND text origins). A static screenshot or zero expansion alone is insufficient. Never silently swallow an explicit enabled trigger: dispatch through the same acknowledged hardware command owner as the corresponding physical action, or show the current blocking reason. - GitHub coordination: before merging read the complete diff, conversations, reviews and every review-thread page (including outdated/unresolved threads); consult github-coordination and docs/GITHUB-COORDINATION.md. Use tools/audit-github-conversations.mjs for exhaustive read-only enumeration. Downloaded records are pending until manually reviewed; closed/merged is not resolved. Request Copilot when useful, distinguish quota/request from completed review, and fix/reply to actual findings. Never bypass required checks or auto-resolve unrelated donor comments. - Capability regexes are NOT exhaustive inventory. Consult donor-audit (optionally -Origin / literal -SourcePath) too; inspect AuditOnly, omissions and remaining actions. Keep the existing authoritative ledger, receipt and owning issue synchronized. On a failure, stop blind retries and record which actual donor source/tool/test was consulted and the selected recovery; never convert unread or unrouted files into exclusions. - Use hostname-first device access. A build, loopback fixture or screenshot is not physical acceptance. diff --git a/README.md b/README.md index 51604ebb..eb844c2c 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg)](LICENSE) [![Rust · egui](https://img.shields.io/badge/Desktop-Rust%20%7C%20egui-f59e0b?logo=rust)](desktop) [![ESP + AVR](https://img.shields.io/badge/Firmware-ESP32--S3%20%2B%20ATmega328P-06b6d4)](firmware) -[![Android preview](https://img.shields.io/badge/Android-preview-8b5cf6?logo=android)](https://github.com/atomicdeploy/PakarController/pull/1) +[![Android preview](https://img.shields.io/badge/Android-preview-8b5cf6?logo=android)](mobile/README.md) -[Overview](#overview) · [Architecture](#architecture) · [Build](#build) · [CI/CD](#continuous-integration--delivery) · [Roadmap](https://github.com/atomicdeploy/PakarController/issues/2) +[Overview](#overview) · [Get started](#get-started) · [Architecture](#architecture) · [Build](#build) · [Guides](docs/README.md) · [Roadmap](https://github.com/atomicdeploy/PakarController/issues/2) @@ -40,6 +40,24 @@ lock and atomic cell-color imports. Rust and Flutter support genuine Excel, CSV and JSON documents; the embedded Web UI supports CSV and JSON. See [board roles, lock semantics and assignment APIs](docs/CELL-ASSIGNMENTS.md). +## Get started + +| Your task | Start here | +|---|---| +| Control a provisioned board | Open its configured hostname in a browser, or select it in the Rust/Android Devices view. Check the returned board identity before issuing commands. | +| Connect by USB, without Wi-Fi | Select **USB** and its actual enumerated port in the desktop app. Only one upstream connection is active; sharing is a separate opt-in. | +| Join a board's access point | Use **Host Wi-Fi** in the client. OS approval and network credentials are separate from API authentication. [Platform behavior](docs/HOST-WIFI.md). | +| Build on a new workstation | Run `pakar.cmd doctor` (or `bash pakar.sh doctor`), then follow [Build](#build). Default plans do not flash or install anything. | +| Develop or hand off work | Use the [documentation hub](docs/README.md), [contribution guide](CONTRIBUTING.md), and [current GitHub PRs](https://github.com/atomicdeploy/PakarController/pulls). | + +### Downloads and verification + +Engineering packages are attached to successful [Build workflow runs](https://github.com/atomicdeploy/PakarController/actions/workflows/build.yml). +Select the intended commit, not just the newest timestamp. Firmware, Windows and +Android outputs are separate artifacts; release packaging adds SHA-256 manifests +and provenance. A successful build is not a deployment or a stable-release claim. +See [release policy and verification](docs/REPOSITORY-GOVERNANCE.md). +
Web device-settings preview — actual embedded UI, simulated device state @@ -229,7 +247,9 @@ Logical IDs retain their 10×8 indexing, but the current visual table has 20 interleaved columns and four rows: `1,11,2,12,...,10,20`. The first column is `1,21,41,61`; the next is `11,31,51,71`, half a cell lower. Cell1 is not offset downward. Each table cell has six LEDs; physical strip order is a separate -editable permutation. Snake is currently compiled out, not a selectable game. +editable permutation. The optional board-owned Snake engine is excluded by +default; the removable host mini-app is documented separately and remains hidden +from normal board menus. See [host mini-app installation](mini-apps/snake/README.md). The interactive terminal now has contextual pages, shared Find/selection, explicit bulk white/red/off actions and the shared CLI command palette. See [terminal controls and remaining acceptance](docs/TERMINAL-UI.md). @@ -238,13 +258,15 @@ Staggered graphics, richer editors and full TUI parity remain open in issue #2. ## Continuous integration & delivery The [CI/CD pipeline](.github/workflows/build.yml) checks generated interface, -branding and donor-evidence contracts, tests both UART build variants, builds +branding and donor-evidence contracts, tests all three AVR link profiles, builds the Windows client and analyzes/tests/builds the Android preview. It stops at the first failed command. Timeouts, superseded-run cancellation and short artifact retention bound wasted work. The tooling matrix checks shared CLI fixtures and guarded command plans on Windows/Linux/macOS; these are not native package or hardware acceptance. [CodeQL](.github/workflows/codeql.yml) scans Actions and JavaScript; it does not claim Rust, AVR C++ or physical hardware coverage. +Maintained entry guides also have a dependency-free local-link and heading check, +adapted from PCController. It runs offline and does not crawl external websites. The expanded pipeline supports version tags and explicit owner dispatch for **draft prereleases**, with Windows and two-board firmware packages, Android preview, SHA-256 checksums, @@ -260,6 +282,7 @@ does not certify that a current GitHub run passed or a device was updated. | Start here | Purpose | |---|---| +| [Documentation hub](docs/README.md) | Task-oriented reading order, tooling and verification boundaries | | [Hardware](docs/HARDWARE.md) | Pin mapping and wiring | | [API](docs/API.md) | Device transport and commands | | [Bluetooth LE](docs/BLUETOOTH.md) | S3 offline Android control, identity, settings and transport limits | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e7b6c269..22443bdf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,6 +2,28 @@ PakarController is integration code built from earlier AtomicDeploy projects. +- `tools/check-doc-links.mjs` adapts `localLinkReference`, Markdown heading + normalization/anchor enumeration and local-link checking from PCController's + `.github/scripts/repository-check.mjs` and `repository-policy.mjs`, read at + `b0111ae0c80dc34149ea1044de7dd5488920301b`. Used under its MIT option; see + `docs/licenses/PCController-MIT.txt`. Pakar adds fenced-example handling for + link scanning, HTML branding links, repository boundaries and offline fixtures; + donor-specific privacy/license/required-path policies are not copied wholesale. + +- `firmware/esp/src/PortableShellCore.h` adapts command descriptors/lookup and + VT100 formatting from `atomicdeploy/portable-shell` revision + `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d` (requested branch + `copilot/organize-readme-into-plan`). Copyright (c) 2024 Portable Shell + Contributors, MIT. The complete license and 16 original regular files are + retained in `third_party/portable-shell/upstream/`; its root CodeQL symlink is + inventoried but not recreated. The original C runtime is reference-only; + the bounded adaptation is used by Pakar's single authenticated device shell. + Tests adapt the donor's command/context/VT100 scenarios and extend them with + non-executing completion, secret isolation and fragmented-input assertions. + `tools/telnet-shell.mjs` also adapts the donor's TCP read/write callback model + into real cross-platform stdin/stdout streams, with incremental Telnet + negotiation, deadlines and backpressure instead of blocking POSIX calls. + - `tools/prepare-avr-image.mjs` adapts Intel HEX validation, AVR jump encoding, unused-vector validation and boot-chain merging from PCController `Tools/Bootloader/Urboot-Custom/build.mjs` at diff --git a/assets/web/application-utilities.css b/assets/web/application-utilities.css index 5cdd4ab9..32dbdf5a 100644 --- a/assets/web/application-utilities.css +++ b/assets/web/application-utilities.css @@ -1,3 +1,4 @@ +.mini-mode-choice{display:flex;align-items:center;gap:12px;width:100%;min-height:64px;text-align:start}.mini-mode-choice>span,.web-menu-item .menu-copy{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px}.mini-mode-choice small,.web-menu-item small{display:block;color:var(--muted);font-size:12px;font-weight:400;line-height:1.4;white-space:normal}.mini-mode-choice strong{line-height:1.3}.studio .web-menu-item:has(.menu-copy){min-height:60px;max-width:430px}.mini-mode-choice>.icon{flex:none;color:var(--accent)} .application-activity{margin:18px 0 0;border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden} .application-activity>summary{min-height:38px;display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;list-style:none;font-size:12px;font-weight:600;line-height:1.25} .application-activity>summary::-webkit-details-marker{display:none}.application-activity>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} @@ -21,3 +22,9 @@ [data-control-density=compact] #workspace :is(button,select,input:not([type=range]):not([type=checkbox]):not([type=color]):not([type=file])){min-height:30px;padding-block:5px;line-height:1.25} [data-control-density=compact] #workspace .studio-square{width:30px;height:30px;padding:5px} @media(max-width:600px){.application-event{grid-template-columns:16px minmax(0,1fr) auto}.application-event time{grid-column:2;grid-row:2}.application-command-open>span{display:none}.application-command-open{width:36px;min-width:36px;height:36px;padding:8px}} +/* Shared file workspace: compact columns on desktop, wrapping on mobile. */ +.file-browser-list{display:flex;flex-direction:column;gap:8px;max-height:55vh;overflow:auto} +.file-browser-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px;border-bottom:1px solid var(--line)} +.file-browser-name{flex:1 1 140px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600} +.file-browser-row .muted{font-size:.85em;flex:1 1 180px} +#userFilesCard progress{width:100%;accent-color:var(--accent)} diff --git a/assets/web/command-transport.js b/assets/web/command-transport.js index fc67c0ef..0486b68d 100644 --- a/assets/web/command-transport.js +++ b/assets/web/command-transport.js @@ -2,6 +2,10 @@ function createCommandTransport({snapshot,socket,http,valid,fallback=true,now=()=>Date.now(),random=()=>crypto.getRandomValues(new Uint8Array(16))}){ const names={'/api/cells':'cells','/api/cell':'cell','/api/game/press':'game-press','/api/game/reset':'reset','/api/game/settings':'game-settings','/api/effect':'effect','/api/mode':'mode','/api/brightness':'brightness','/api/led/output':'led-output','/api/cells/lock':'cell-lock','/api/cells/assignments':'cell-assignments','/api/led/diagnostic':'pixel-diagnostic','/api/led/presentation':'state-presentation','/api/led/preview':'color-preview'}; const pending=new Map(); + names['/api/pixels/corrections/preview']='pixel-corrections-preview'; + names['/api/presentation']='presentation'; + names['/api/audio']='audio';names['/api/buzzer']='buzzer';names['/api/outputs']='outputs'; + for(const action of ['configure','control','input'])names['/api/minigame/'+action]='minigame-'+action; function receive(reply){const p=pending.get(reply?.request_id);if(!p)return false;if(reply.type!=='command-result'||reply.board_id!==p.board||reply.boot_id!==p.boot)return false;p.reply=reply;p.resolve(reply);return true} const integer=(value,key,min=0,max=Number.MAX_SAFE_INTEGER)=>{ if(typeof value==='string'&&!/^(?:0|[1-9]\d*)$/.test(value))throw Error('Invalid '+key); @@ -17,6 +21,41 @@ function createCommandTransport({snapshot,socket,http,valid,fallback=true,now=() }; function typedValues(path,values){ const result={...values}; + if(path==='/api/minigame/configure'){ + const settings=structured(result.settings,'minigame settings'); + const limits={limit_ms:[0,86400000],warning_ms:[0,86400000],urgent_ms:[0,86400000],penalty_ms:[0,86400000],window_ms:[100,60000],show_ms:[100,10000],gap_ms:[50,5000],feedback_ms:[50,5000],intro_ms:[100,10000],preview_ms:[0,10000],mismatch_ms:[600,10000],rounds:[1,32],sequence_length:[1,32],palette_size:[2,9],fixed_target:[0,9]}; + for(const key of Object.keys(settings))if(!Object.hasOwn(limits,key)&&!['mode','hard','intro_enabled','color_arpeggio','toggle_kind','outputs'].includes(key))throw Error('Unknown minigame setting'); + for(const [key,[min,max]] of Object.entries(limits))if(key in settings)settings[key]=integer(settings[key],key,min,max); + for(const key of ['hard','intro_enabled','color_arpeggio'])if(key in settings)settings[key]=boolean(settings[key],key); + if('mode' in settings&&!snapshot().minigame?.available_modes?.some(mode=>mode.id===settings.mode))throw Error('Unavailable minigame'); + if('toggle_kind' in settings&&!['toggle','latch','press'].includes(settings.toggle_kind))throw Error('Invalid toggle behavior'); + if('outputs' in settings){ + const rows=structured(settings.outputs,'minigame outputs',true); + if(rows.length!==8)throw Error('Expected eight output mappings'); + settings.outputs=rows.map(row=>{ + const sources=['disabled','active','success','failure','held','latched','winner','lost']; + if(!sources.includes(row?.source))throw Error('Invalid output source'); + const key=integer(row.key,'output key',0,9); + if((sources.indexOf(row.source)<4)!==(key===0))throw Error('Invalid output key'); + return {source:row.source,key}; + }); + } + result.settings=settings; + } + if(path==='/api/minigame/control'&&!['start','pause','resume','reset','stop'].includes(result.action))throw Error('Invalid minigame action'); + if(path==='/api/minigame/input'){ + result.key=integer(result.key,'key',1,9); + if(!['tap','down','up'].includes(result.action))throw Error('Invalid minigame input'); + } + if(path==='/api/pixels/corrections/preview')result.configuration=structured(result.configuration,'correction preview'); + if(path==='/api/presentation')return structured(result.presentation,'host presentation'); + if(path==='/api/audio'&&(typeof result.cue!=='string'||!/^[a-z0-9][a-z0-9-]{0,39}$/.test(result.cue)))throw Error('Invalid melody'); + if(path==='/api/buzzer'){ + result.frequency=integer(result.frequency,'frequency',0,20000); + if(result.frequency>0&&result.frequency<20)throw Error('Invalid frequency'); + result.duration_ms=integer(result.duration_ms,'duration_ms',1,65535); + } + if(path==='/api/outputs')result.mask=integer(result.mask,'mask',0,255); for(const key of ['id','key','index','count','rgb','group_layout_revision'])if(key in result)result[key]=integer(result[key],key); if(path==='/api/brightness')result.value=integer(result.value,'brightness',0,255); for(const key of ['enabled','off'])if(key in result)result[key]=boolean(result[key],key); @@ -51,6 +90,10 @@ function createCommandTransport({snapshot,socket,http,valid,fallback=true,now=() } async function send(path,values){ const observed=snapshot();if(!names[path]||!valid()||!Number.isSafeInteger(observed.command_clock_ms)||observed.command_clock_ms<0||observed.command_clock_ms>Number.MAX_SAFE_INTEGER-8000)return null; + if(path.startsWith('/api/minigame/')&&observed.minigame?.ready!==true)return null; + const miniRevision=observed.minigame?.revision??0; + const avr=['/api/audio','/api/buzzer','/api/outputs'].includes(path); + if(avr&&(!observed.avr_online||!observed.capabilities?.acknowledged_features?.includes('avr-command-ack')))return null; // Capture immutable identity values, not a mutable snapshot object. const s={board_id:observed.board_id,boot_id:observed.boot_id,command_clock_ms:observed.command_clock_ms}; const request_id=Array.from(random(),x=>x.toString(16).padStart(2,'0')).join(''); @@ -77,8 +120,17 @@ function createCommandTransport({snapshot,socket,http,valid,fallback=true,now=() } if(!same()||reply?.type!=='command-result'||reply.request_id!==request_id||reply.board_id!==s.board_id||reply.boot_id!==s.boot_id)throw Error('Uncorrelated command receipt; inspect device state'); if(reply.accepted!==true)throw Error(reply.error||'Command rejected'); + if(avr&&reply.avr_acknowledged!==true)throw Error('AVR did not acknowledge the command'); if((path==='/api/game/reset'||path==='/api/game/press')&&reply.avr_acknowledged!==true)throw Error('AVR did not acknowledge the command'); if(reply.status?.board_id!==s.board_id||reply.status?.boot_id!==s.boot_id)throw Error('Command readback missing'); + if(path.startsWith('/api/minigame/')){ + const actual=reply.status.minigame; + if(!actual||!Number.isSafeInteger(actual.revision)||actual.revisionread[i]?.source===rule.source&&read[i]?.key===rule.key):read===value; + if(!matches)throw Error('Minigame settings readback differs'); + } + } return reply; }finally{clearTimeout(timer);clearTimeout(deadlineTimer);httpController?.abort();pending.delete(request_id)} } diff --git a/assets/web/controller-copy.json b/assets/web/controller-copy.json index bdcc9a9a..0060f01b 100644 --- a/assets/web/controller-copy.json +++ b/assets/web/controller-copy.json @@ -108,7 +108,6 @@ "Effects": "جلوه‌ها", "Rainbow test": "آزمایش رنگین‌کمان", "LED driver: {driver}": "راه‌انداز LED: {driver}", - "Static cells: six matching pixels. Rainbow test animates explicitly.": "خانه‌های ثابت: شش پیکسل هم‌رنگ. آزمایش رنگین‌کمان، پویانمایی را فعال می‌کند.", "Effects: animated output enabled explicitly.": "جلوه‌ها: خروجی متحرک فعال شده است.", "Logical cell {id}": "خانهٔ منطقی {id}", "Collapse or expand {panel}": "جمع یا باز کردن {panel}", @@ -301,5 +300,11 @@ "Rule preview": "پیش‌نمایش قاعده", "Send black to this address. Later pixels keep their positions.": "این نشانی خاموش می‌شود و پیکسل‌های بعدی در جای خود می‌مانند.", "Skip this value. Following values move one address earlier; the last address is black.": "این مقدار حذف می‌شود؛ مقدارهای بعدی یک نشانی جلو می‌آیند و نشانی آخر خاموش می‌شود.", - "Move this pixel and all following values. Positive moves later; negative moves earlier. Empty addresses are black.": "این پیکسل و مقدارهای پس از آن جابه‌جا می‌شوند؛ مثبت به عقب و منفی به جلو. نشانی‌های خالی خاموش می‌شوند." + "Move this pixel and all following values. Positive moves later; negative moves earlier. Empty addresses are black.": "این پیکسل و مقدارهای پس از آن جابه‌جا می‌شوند؛ مثبت به عقب و منفی به جلو. نشانی‌های خالی خاموش می‌شوند.", + "Fire direction": "جهت شعله", + "0° · Up": "۰° · بالا", + "90° · Right": "۹۰° · راست", + "180° · Down": "۱۸۰° · پایین", + "270° · Left": "۲۷۰° · چپ", + "Static groups: matching pixels. Rainbow and Fire animate when selected.": "گروه‌های ثابت: پیکسل‌های هم‌رنگ. رنگین‌کمان و آتش با انتخاب متحرک می‌شوند." } diff --git a/assets/web/feature-workspaces.js b/assets/web/feature-workspaces.js index ebd80e4f..08279178 100644 --- a/assets/web/feature-workspaces.js +++ b/assets/web/feature-workspaces.js @@ -175,6 +175,8 @@ const corrections=card('pixelCorrectionsCard','Pixel corrections','pixel-corrections','corrections'); const diagnostics=card('pixelDiagnosticsCard','Pixel diagnostics','pixel-diagnostic','diagnostics'); const inspector=card('apiInspectorCard','API inspector','identity','api'); + const fileCard=root.UserFiles?card('userFilesCard','Files','managed-files','folder'):null; + const userFiles=fileCard?root.UserFiles.create({...options,...fileCard,node,button,select,api,pin,snapshot,peer}):null; function reveal(target){if(!target)return;if(options.reveal)options.reveal(target);else root.studioReveal?.(target);target.scrollIntoView({behavior:'smooth',block:'start'});} const groupCard=root.PixelGroups?card('pixelGroupsCard','Pixel groups','pixel-group-layout','mapping'):null; const groups=groupCard?root.PixelGroups.create({...options,...groupCard,node,field,numeric,button,select,snapshot,peer,api,command,pin,report,reveal,diagnostics:diagnostics.card,corrections:corrections.card}):null; @@ -195,7 +197,20 @@ rows.tabIndex=0;rows.setAttribute('role','region');rows.setAttribute('aria-label',translate('Correction rules')); corrections.body.append(fixed,rows,mapDisclosure); function message(text,error=false){notice.textContent=translate(text);notice.classList.toggle('field-error',error);} - function changed(){if(!model)return;correctionEdits++;model.dirty=true;message(model.conflict?'Board changed elsewhere. Reload before saving.':'Unsaved changes',model.conflict);sync();if(model.mappingView==='draft')drawMapping();rememberPresentation();} + const livePreview=input('checkbox');livePreview.checked=true; + const livePreviewField=field('Live preview',livePreview);fixed.append(livePreviewField); + const preview=root.createPixelCorrectionPreview?.({ + owner:()=>current(owner)?{key:owner,pin:pin?.()}:null, + allowed:()=>current(owner)&&peer().has('pixel-corrections-preview')&&!!model?.saved&&!model.conflict&&validCorrectionDraft(model), + configuration:()=>({revision:model.saved.revision,enabled:model.enabled,rules:model.rules}), + pixels:()=>model.saved.pixels,project:correctionSources, + send:async(value,identity)=>{requireCurrent(identity.key);const reply=await command(correctionPath+'/preview',{configuration:JSON.stringify(value)});requireCurrent(identity.key);return reply.pixel_corrections_preview?{revision:value.revision,preview:reply.pixel_corrections_preview}:reply;}, + read:async identity=>{requireCurrent(identity.key);return api(correctionPath,identity.pin?{peer:identity.pin}:{});}, + onChange:state=>{if(state.error)message(state.error,true);} + }); + livePreview.onchange=()=>{if(!livePreview.checked)void preview?.stop();else if(model?.dirty)startPreview();}; + function startPreview(){if(!livePreview.checked||!preview||!validCorrectionDraft(model)||model.conflict)return;if(preview.snapshot().enabled)preview.edited();else void preview.start();} + function changed(){if(!model)return;correctionEdits++;model.dirty=true;message(model.conflict?'Board changed elsewhere. Reload before saving.':'Unsaved changes',model.conflict);sync();if(model.mappingView==='draft')drawMapping();rememberPresentation();startPreview();} enabled.onchange=()=>{if(!writable()||!model?.saved)return;model.enabled=enabled.checked;changed();}; const validNumericDraft=(text,min,max)=>text===undefined||text.trim()!==''&&Number.isSafeInteger(Number(text))&&Number(text)>=min&&Number(text)<=max; const validCorrectionDraft=record=>!!record?.saved&&record.rules.every(rule=>validNumericDraft(rule.pixelDraft,1,record.saved.pixels)&&(rule.kind!=='shift'||validNumericDraft(rule.offsetDraft,-record.saved.pixels,record.saved.pixels))); @@ -238,14 +253,18 @@ filter.oninput=()=>{drawMapping();rememberPresentation();};mapDisclosure.ontoggle=()=>{drawMapping();rememberPresentation();}; const rowRefreshers=[],cursorActions=[],rulePreviews=[];let correctionEdits=0,addressContextKey=''; function refreshRulePreviews(){if(!rulePreviews.some(view=>view.open()))return;const key=mappingColorKey(),rgb=correctionRgb(snapshot(),model?.saved?.pixels,peer().role,current(owner));for(const view of rulePreviews)view.refresh(key,rgb);} - // Flutter selectedDiagnosticPixel/importPixel: copy the selected OUTPUT - // cursor, never its projected raw source or a second 'lit pixel' guess. + // Diagnostics select RAW inputs. Rules select WIRE destinations. Resolve + // through the saved map; never migrate existing rule coordinates. function selectedCorrectionCursor(){ const p=model?.probe,s=diagnosticShape(snapshot(),peer().role); if(!current(owner)||!peer().has('pixel-diagnostic')||!s||!p||(!p.dirty&&!p.observation))return null; if(position.value===''||!position.checkValidity())return null; const index=Number(position.value)-1,used=s.layout?.used_pixels||s.pixels; - return Number.isInteger(index)&&index>=0&&index=used||model.conflict||(model.dirty&&preview?.snapshot().enabled))return null; + const sources=model.saved?.sources; + if(!Array.isArray(sources)||sources.length!==model.saved.pixels)return null; + const destinations=sources.flatMap((source,wire)=>source===index?[wire]:[]); + return destinations.length===1?destinations[0]:null; } function refreshCursorActions(){const index=selectedCorrectionCursor();for(const refresh of cursorActions)refresh(index);} function drawRows(){ @@ -325,6 +344,9 @@ try{ const lease=pin?.(); const wanted={revision:record.saved?.revision,enabled:record.enabled,rules:record.rules.map(({kind,pixel,offset,enabled})=>({kind,pixel,offset,enabled}))}; + // Retire RAM preview before explicit save/reload. A failed release + // leaves the draft intact and never overwrites the stored rules. + if(preview&&(preview.snapshot().enabled||preview.snapshot().lease!==null||preview.snapshot().pending)&&!await preview.stop())throw Error('Preview release not confirmed; retry after it expires'); if(save)await command(correctionPath,{configuration:JSON.stringify(wanted)}); requireCurrent(expected);const result=parseCorrections(await api(correctionPath,lease?{peer:lease}:{}));requireCurrent(expected); if(save&&(!correctionsMatch(result,wanted)||result.revision{ + await new Promise(resolve=>{ const prompt=ui().dialog({title:translate('Discard correction edits?'),closeLabel:translate('Close'), description:translate('Reload keeps the saved board configuration and discards only these edits.'), actions:[{label:translate('Cancel'),icon:'close',autofocus:true}, - {label:translate('Reload'),icon:'refresh',variant:'primary',action:()=>resolve(true)}]}); + {label:translate('Reload'),icon:'refresh',variant:'primary',action:async()=>{ + if(reloadPrompt!==prompt||!current(expected)||model!==record||!writable())return false; + // Keep the modal's async owner until the read restores enabled + // controls, so native close can return focus to Reload. + await load();resolve(true); + }}]}); reloadPrompt=prompt; prompt.element.addEventListener('close',()=>{if(reloadPrompt===prompt)reloadPrompt=null;resolve(false);},{once:true}); }); - if(!approved)return; + return; } requireCurrent(expected); if(model!==record)throw Error('Device session changed. Edits were kept.'); @@ -363,6 +390,7 @@ const reload=button('Reload',reloadCorrections,'refresh');tools.append(add,save,reload); function sync(){ const ready=writable()&&!!model?.saved; + livePreviewField.hidden=!preview||!peer().has('pixel-corrections-preview');livePreview.disabled=!ready; for(const control of rows.querySelectorAll('input,select,button'))control.disabled=!ready||control.dataset.boundary==='true'; enabled.disabled=!ready;add.disabled=!ready||model.rules.length>=32;save.disabled=!ready||!model.dirty||model.conflict||!validCorrectionDraft(model);reload.disabled=!current(owner)||!peer().has('pixel-corrections')||model?.busy===true; diagnosticLink.disabled=!peer().has('pixel-diagnostic')&&!peer().has('streaming'); @@ -506,6 +534,7 @@ const link=button('Pixel corrections',()=>reveal(corrections.card),'corrections');link.dataset.openCorrections='true';diagnostic.append(link);} function update(value=snapshot()){ if(disposed)return;const next=identity(value),c=peer(); + preview?.observe(); if(reloadPrompt&&(!c.known||!c.has('pixel-corrections')))dismissReload(); if(next!==owner){dismissReload();owner=next;model=next?models.get(next):null; if(next&&!model){const scope=options.workspaceScope?.(value)||'',draft=scope?diagnosticWorkspace(options.readWorkspace?.(scope)):null,presentation=pixelToolsWorkspace(scope?options.readPresentation?.(scope):null)||pixelToolsWorkspace({}); @@ -523,14 +552,14 @@ if(!model.attempted&&!model.busy)void load(); } for(const link of document.querySelectorAll('[data-open-corrections]'))link.hidden=!c.has('pixel-corrections'); - for(const link of groupLinks)link.hidden=!c.has('pixel-group-layout');groups?.update(value); + for(const link of groupLinks)link.hidden=!c.has('pixel-group-layout');groups?.update(value);userFiles?.update(); observeProbe(value);inspectRoutes();sync();syncProbe();syncInspector(); if(mapDisclosure.open&&mappingKey()!==mapStateKey)drawMapping(); if(c.known&&endpoints.value==='/api/status'&&!inspectorBusy){inspectorPayload=redact(value);response.textContent=JSON.stringify(inspectorPayload,null,2);inspectorStatus.textContent=translate('Live snapshot');} } const beforeUnload=event=>{if(groups?.dirty()||[...models.values()].some(record=>record.dirty||record.busy)){event.preventDefault();event.returnValue='';}}; root.addEventListener('beforeunload',beforeUnload); - installed={update,workspaceSnapshot(scope){return model&&model.scope===scope?diagnosticWorkspace(model.diagnosticDraft):undefined;},presentationSnapshot(scope){return model&&model.scope===scope?pixelToolsWorkspace(model.presentation):undefined;},dispose(){disposed=true;dismissReload();root.removeEventListener('beforeunload',beforeUnload);groups?.dispose();corrections.card.remove();diagnostics.card.remove();inspector.card.remove();installed=null;}}; + installed={update,workspaceSnapshot(scope){return model&&model.scope===scope?diagnosticWorkspace(model.diagnosticDraft):undefined;},presentationSnapshot(scope){return model&&model.scope===scope?pixelToolsWorkspace(model.presentation):undefined;},dispose(){void preview?.dispose();disposed=true;dismissReload();root.removeEventListener('beforeunload',beforeUnload);groups?.dispose();userFiles?.dispose();corrections.card.remove();diagnostics.card.remove();inspector.card.remove();installed=null;}}; update();return installed; } root.createFeatureWorkspaces=create; diff --git a/assets/web/localization.js b/assets/web/localization.js index 2ff064ed..c355884c 100644 --- a/assets/web/localization.js +++ b/assets/web/localization.js @@ -1,7 +1,7 @@ // Pealayer web_ui/src/i18n.ts::tr semantics: translate application copy only. // Generated shared dictionary follows; peer values never pass through tr. // BEGIN SHARED PERSIAN DICTIONARY -const PAKAR_FA={"Disconnected":"قطع‌شده","Hardware":"سخت‌افزار","Network":"شبکه","Pulse":"تپش","Revision":"بازنگری","Import colors…":"ورود رنگ‌ها…","Import cell colors":"ورود رنگ خانه‌ها","Imported":"واردشده","Board sound":"صدای برد","AVR name":"نام AVR","Mutes the physical board buzzer, including welcome and menu sounds. Saves directly on the board; browser sound is separate.":"بیزر خود برد، از جمله صدای خوشامد و منوها را بی‌صدا می‌کند. مستقیم روی برد ذخیره می‌شود؛ صدای مرورگر جدا است.","Appearance is saved in this browser. Board sound changes save directly to the board.":"ظاهر در این مرورگر ذخیره می‌شود. تغییرات صدای برد مستقیم روی برد ذخیره می‌شوند.","Programming safety keeps the board muted.":"حفاظت هنگام برنامه‌ریزی، برد را بی‌صدا نگه می‌دارد.","Verified · saved in EEPROM":"تأیید شد · ذخیره‌شده در EEPROM","Reading board…":"در حال خواندن برد…","LEDs & cells":"LEDها و خانه‌ها","AVR & inputs":"AVR و ورودی‌ها","Firmware & storage":"میان‌افزار و ذخیره‌سازی","Search settings…":"جست‌وجوی تنظیمات…","Close device settings":"بستن تنظیمات دستگاه","Device settings pages":"صفحه‌های تنظیمات دستگاه","Discard & reload…":"لغو تغییرات و خواندن دوباره…","Read the device to load settings":"برای بارگذاری تنظیمات، دستگاه را بخوانید","Only fresh, acknowledged device capabilities are shown. Application preferences are separate from board configuration.":"فقط قابلیت‌های تازه و تأییدشدهٔ دستگاه نمایش داده می‌شوند. تنظیمات برنامه از پیکربندی برد جدا هستند.","Firmware build information · read-only":"اطلاعات ساخت میان‌افزار · فقط خواندنی","UART flow-control mode, pin ownership and hardware layout are build flags, not runtime switches. Rebuild and safely update firmware to change them.":"حالت کنترل جریان UART، تخصیص پایه‌ها و چیدمان سخت‌افزار گزینه‌های زمان ساخت هستند. برای تغییر آن‌ها، میان‌افزار را دوباره بسازید و به‌صورت ایمن به‌روز کنید.","No observed build information":"اطلاعات ساخت دریافت نشده است","Build information not reported by this peer":"این دستگاه اطلاعات ساخت را گزارش نکرده است","Use the same live workspace controls; settings does not duplicate device editors.":"از کنترل‌های زندهٔ فضای کاری استفاده کنید؛ ویرایشگرهای دستگاه در تنظیمات تکرار نمی‌شوند.","Table & mapping":"میز و نگاشت","Text presentation":"نمایش متن","Buzzer & melodies":"بیزر و ملودی‌ها","Waiting for acknowledgment and saved-settings readback…":"در انتظار تأیید و بازخوانی تنظیمات ذخیره‌شده…","Settings changed on another client. Your edits are preserved; discard and reload before saving.":"تنظیمات در کلاینت دیگری تغییر کرده‌اند. ویرایش‌های شما حفظ شده‌اند؛ پیش از ذخیره، تغییرات را لغو کنید و دوباره بخوانید.","Unsaved device edits":"ویرایش‌های ذخیره‌نشدهٔ دستگاه","Observed device settings; no pending edits":"تنظیمات دستگاه دریافت شده‌اند؛ ویرایش ذخیره‌نشده‌ای نیست","Reconnect to read device settings":"برای خواندن تنظیمات دستگاه دوباره متصل شوید","Station SSID":"نام شبکهٔ ایستگاه","Open station network · no password":"شبکهٔ باز ایستگاه · بدون گذرواژه","Station enabled":"ایستگاه فعال","Recovery AP enabled":"نقطهٔ دسترسی بازیابی فعال","Scanned networks":"شبکه‌های یافت‌شده","Enable MQTT":"فعال‌کردن MQTT","Broker hostname":"نام میزبان بروکر","Base topic":"موضوع پایه","Password handling":"مدیریت گذرواژه","Replacement password":"گذرواژهٔ جایگزین","Enable outbound WebSocket":"فعال‌کردن وب‌سوکت خروجی","Server hostname":"نام میزبان سرور","Path":"مسیر","Token handling":"مدیریت توکن","Replacement token":"توکن جایگزین","Telemetry interval · seconds":"فاصلهٔ دورسنجی · ثانیه","Keep stored password":"حفظ گذرواژهٔ ذخیره‌شده","Leave password unset":"بدون گذرواژه","Keep stored token":"حفظ توکن ذخیره‌شده","Leave token unset":"بدون توکن","Wait for the current operation to finish":"منتظر پایان عملیات جاری بمانید","Saved public settings verified by device readback. Secret values remain write-only.":"تنظیمات عمومی با بازخوانی دستگاه تأیید شدند. مقادیر محرمانه فقط نوشتنی می‌مانند.","No acknowledged workspace panels":"پنل تأییدشده‌ای برای فضای کاری نیست","Reset panel layout":"بازنشانی چیدمان پنل‌ها","Show":"نمایش","System":"سیستم","Dark":"تیره","Light":"روشن","Apply":"اعمال","Sign out":"خروج از حساب","Select all":"انتخاب همه","Manage cells…":"مدیریت خانه‌ها…","Toggle without Ctrl":"انتخاب چندتایی بدون Ctrl","Animate cells":"پویانمایی خانه‌ها","LED-state indicators":"نشانگرهای وضعیت LED","Edit order":"ویرایش ترتیب","Reset order":"بازنشانی ترتیب","Cell shape":"شکل خانه","Rounded":"گوشه‌های گرد","Circle · six pixels":"دایره · شش پیکسل","Hexagon · six pixels":"شش‌ضلعی · شش پیکسل","Physical pixel indices":"شمارهٔ پیکسل‌های فیزیکی","Board modes":"حالت‌های برد","Live device":"دستگاه زنده","Clients":"کلاینت‌ها","Station":"ایستگاه","Recovery AP":"نقطهٔ دسترسی بازیابی","Cloud WebSocket":"وب‌سوکت ابری","Cell identity":"هویت خانه","Cell action":"عملیات خانه","Addressable LEDs":"LEDهای آدرس‌پذیر","Save logical / physical mapping":"ذخیرهٔ نگاشت منطقی و فیزیکی","Stream rainbow":"ارسال جریان رنگین‌کمان","Transition":"گذار","Live display":"نمایشگر زنده","Show number":"نمایش عدد","Text":"متن","Scroll short text":"پیمایش متن کوتاه","Repeat":"تکرار","Present":"نمایش متن","Present text":"نمایش متن","Stop text":"توقف متن","Inputs":"ورودی‌ها","Board identity":"هویت برد","Save board identity":"ذخیرهٔ هویت برد","Device access":"دسترسی به دستگاه","Device configuration":"پیکربندی دستگاه","Error":"خطا","Wi-Fi":"وای‌فای","Port":"درگاه","Mapping":"نگاشت","Logical":"خانهٔ منطقی","Logical cell":"خانهٔ منطقی","Physical":"خانهٔ فیزیکی","Find cells: 12, 3–8":"یافتن خانه‌ها: 12, 3–8","Search device settings":"جست‌وجوی تنظیمات دستگاه","Application":"برنامه","Device":"دستگاه","Device settings…":"تنظیمات دستگاه","Device settings":"تنظیمات دستگاه","Preferences…":"تنظیمات…","Table":"میز","Pixel diagnostics":"عیب‌یابی پیکسل‌ها","Pixel corrections":"اصلاح پیکسل‌ها","Hardware monitor":"پایش سخت‌افزار","Events":"رویدادها","Theme":"پوسته","Palette":"پالت رنگ","Control density":"تراکم کنترل‌ها","Compact":"فشرده","Regular":"معمولی","Comfortable":"راحت","Search settings":"جست‌وجوی تنظیمات","Username":"نام کاربری","Password":"گذرواژه","Refresh":"تازه‌سازی","Find cell":"یافتن خانه","Manage cells":"مدیریت خانه‌ها","Current":"کنونی","File":"پرونده","Edit":"ویرایش","Audio":"صدا","Workspace":"فضای کاری","Workspaces":"فضاهای کاری","Restore":"بازیابی","Active":"فعال","Window":"پنجره","Panels":"پنل‌ها","Language":"زبان","Touch feedback":"بازخورد لمسی","System language":"زبان سیستم","English":"انگلیسی","Persian":"فارسی","Direction":"جهت","Automatic":"خودکار","Left to right":"چپ به راست","Right to left":"راست به چپ","Left":"چپ","Right":"راست","Replacement":"جایگزین","Connect":"اتصال","Connecting…":"در حال اتصال…","Disconnect":"قطع اتصال","Identity":"هویت","Order":"ترتیب","Move up":"انتقال به بالا","Move down":"انتقال به پایین","Presentation":"نحوهٔ نمایش","Manage":"مدیریت","Publish":"انتشار","Type":"نوع","Control":"کنترل","Board":"برد","Color palette":"پالت رنگ","Studio":"استودیو","Neutral":"خنثی","New":"جدید","Description":"توضیحات","Overview":"نمای کلی","Capabilities":"قابلیت‌ها","Front panel":"پنل جلویی","Red":"قرمز","Green":"سبز","White":"سفید","Advanced":"پیشرفته","Save to board":"ذخیره در برد","Board name":"نام برد","Not persisted":"ذخیره نشده","Mode":"حالت","Pixel count":"تعداد پیکسل‌ها","pixels":"پیکسل","Single pixel":"یک پیکسل","Rainbow":"رنگین‌کمان","Color":"رنگ","Pixel":"پیکسل","Stop stream":"توقف جریان","Streaming":"در حال پخش","Idle":"آماده","Seven-segment display":"نمایشگر هفت‌بخشی","LCD display":"نمایشگر LCD","Controls":"کنترل‌ها","Page":"صفحه","Previous":"قبلی","Next":"بعدی","Decrease":"کاهش","Increase":"افزایش","Select":"انتخاب","Brightness":"روشنایی","Silent mode":"حالت بی‌صدا","No":"خیر","Connection":"اتصال","Reconnect":"اتصال دوباره","Connected":"متصل","Hold":"نگه‌داشتن","Toggle":"تغییر حالت","Segments":"نمایشگر هفت‌بخشی","Protocol":"پروتکل","Ready":"آماده","Box":"جعبه","Column":"ستون","Row":"ردیف","Button":"دکمه","Indicator":"نشانگر","Winner":"برنده","Lost":"بازنده","Transmit":"ارسال","Hide":"پنهان‌کردن","hidden":"پنهان","Lock":"قفل","Visibility":"نمایانی","Physical status RGB":"چراغ RGB فیزیکی","Telemetry":"دورسنجی","Search":"جست‌وجو","Connected board":"برد متصل","Group":"گروه","ON":"روشن","Discard":"دور انداختن","Stop preview":"توقف پیش‌نمایش","Preview":"پیش‌نمایش","Close":"بستن","Save":"ذخیره","Preferences":"تنظیمات","Unsaved changes":"تغییرات ذخیره‌نشده","Import":"درون‌ریزی","Export":"برون‌ریزی","Target":"مقصد","Disabled":"غیرفعال","Copy":"رونوشت","Status":"وضعیت","Server":"سرور","Open":"باز کردن","Cancel":"انصراف","Action":"عملکرد","Enabled":"فعال","Sound":"صدا","Appearance":"ظاهر","Pealayer green":"سبز پی‌لیر","Windows blue":"آبی ویندوز","macOS blue":"آبی مک‌اواس","Custom":"سفارشی","Custom accent":"رنگ تأکیدی سفارشی","Reset":"بازنشانی","Icon":"نماد","Presets":"گزینه‌های آماده","Name":"نام","On":"روشن","Off":"خاموش","Actions":"عملیات","cues":"نشانه","selected":"انتخاب‌شده","Source":"منبع","Output":"خروجی","Name:":"نام:","Effect":"جلوه","Play":"پخش","Pause":"مکث","Stop":"توقف","Mute":"قطع صدا","Unknown":"ناشناخته","End":"پایان","Step":"پله‌ای","Time":"زمان","Value":"مقدار","Enable rule":"فعال‌کردن قاعده","Correction rules":"قواعد اصلاح پیکسل","Arrange panels":"چیدمان پنل‌ها","Done":"پایان","Fit":"اندازهٔ پنل","Fit table to panel":"اندازهٔ میز برابر با پنل","Zoom out":"کوچک‌نمایی","Zoom in":"بزرگ‌نمایی","Go to":"رفتن به","Go to panel":"رفتن به پنل","Select {state}":"انتخاب {state}","LED transmitter":"فرستنده LED","GPIO · simple":"GPIO · ساده","RMT + DMA":"RMT + DMA","No active transmitter":"فرستنده فعال نیست","Switching to {backend}…":"در حال تغییر به {backend}…","Active: {backend}":"فعال: {backend}","Transmitter not ready · {backend}":"فرستنده آماده نیست · {backend}","LED transmitter change failed · {error} · active: {backend}":"تغییر فرستنده LED ناموفق بود · {error} · فعال: {backend}","LED transmitter change was accepted, but active state is unconfirmed. No command was replayed.":"تغییر فرستنده LED پذیرفته شد، اما وضعیت فعال تأیید نشده است. فرمان دوباره ارسال نشد.","Device session changed; command was not replayed":"نشست دستگاه تغییر کرد؛ فرمان دوباره ارسال نشد","All panels":"همهٔ پنل‌ها","Read-only":"فقط خواندنی","Lock lost cells":"قفل خانه‌های بازنده","Export JSON":"خروجی JSON","Export CSV":"خروجی CSV","Apply assignments":"اعمال رنگ‌های تخصیص‌یافته","Cell":"خانه","Language & input":"زبان و ورودی","Preferences · This browser":"تنظیمات · این مرورگر","Close appearance":"بستن تنظیمات ظاهر","Apply preferences":"اعمال تنظیمات","Accent":"رنگ تأکیدی","System / default blue":"سیستم / آبی پیش‌فرض","System follows this browser’s light/dark setting.":"حالت سیستم از تنظیم روشن یا تیرهٔ این مرورگر پیروی می‌کند.","Actual shared Pealayer palette definitions.":"پالت‌های رنگ مشترک با پی‌لیر.","System uses blue in this standalone device UI.":"رنگ پیش‌فرض سیستم در این رابط دستگاه، آبی است.","Button text contrast is selected automatically.":"رنگ متن دکمه برای خوانایی، خودکار انتخاب می‌شود.","Silent mode ·":"حالت بی‌صدا ·","Use your configured device credentials.":"مشخصات ورود تنظیم‌شدهٔ دستگاه را وارد کنید.","Sign in":"ورود","Connecting to device…":"در حال اتصال به دستگاه…","Retry connection":"تلاش دوباره برای اتصال","Device access storage is unavailable. USB recovery is required.":"ذخیره‌سازی دسترسی دستگاه در دسترس نیست. بازیابی با USB لازم است.","Connection unavailable. Retry to read the device.":"اتصال در دسترس نیست. برای خواندن دستگاه دوباره تلاش کنید.","Sign in to this device.":"به این دستگاه وارد شوید.","First login: admin / pakarcontroller. Change these in Device access after signing in.":"ورود نخست: admin / pakarcontroller. پس از ورود، این مقادیر را در بخش دسترسی دستگاه تغییر دهید.","AtomicDeploy control surface":"پنل کنترل AtomicDeploy","WebSocket —":"وب‌سوکت —","WebSocket retrying":"تلاش دوباره برای وب‌سوکت","WebSocket live":"وب‌سوکت زنده","Reconnect WebSocket":"اتصال دوبارهٔ وب‌سوکت","Disconnect · use HTTP":"قطع وب‌سوکت · استفاده از HTTP","HTTP refresh · WS off":"تازه‌سازی HTTP · وب‌سوکت خاموش","HTTP status refresh continues. USB transport is managed by the desktop app.":"وضعیت از طریق HTTP تازه‌سازی می‌شود. اتصال USB در برنامهٔ دسکتاپ مدیریت می‌شود.","Panels ▾":"پنل‌ها ▾","Table board":"برد میز","Controller":"کنترلر","Table presentation":"نمایش میز","Game":"بازی","Cell selection":"انتخاب خانه‌ها","First wins":"اولین فشردن برنده است","Waiting for board capabilities…":"در انتظار قابلیت‌های برد…","Capabilities unavailable or stale. Connect to a compatible board.":"قابلیت‌ها در دسترس یا به‌روز نیستند. به برد سازگار متصل شوید.","Read-only: refresh to obtain a supported board/boot identity":"فقط خواندنی: برای دریافت هویت معتبر برد و راه‌اندازی، تازه‌سازی کنید","Top first · CCW · indices 0–479 · sampled RGB before brightness":"شروع از بالا · پادساعت‌گرد · شماره‌های ۰ تا ۴۷۹ · RGB نمونه‌برداری‌شده پیش از اعمال روشنایی","Click selects · Ctrl toggles · Shift extends · drag selects · Enter manages":"کلیک: انتخاب · Ctrl: تغییر انتخاب · Shift: گسترش · کشیدن: انتخاب محدوده · Enter: مدیریت","Find logical cells":"یافتن خانه‌های منطقی","80 logical cells":"۸۰ خانهٔ منطقی","Clear selection":"پاک‌کردن انتخاب","State":"وضعیت","Fade":"تغییر تدریجی","Immediate":"آنی","fade":"تغییر تدریجی","immediate":"آنی","pulse":"تپش","none":"بدون جلوه","rainbow":"رنگین‌کمان","fire":"آتش","Once":"یک بار","Loop":"تکرار پیوسته","Interval":"با فاصله","Buzzer":"بیزر","Play cue":"پخش ملودی","Available melody":"ملودی‌های موجود","Live TM1637 preview":"پیش‌نمایش زندهٔ TM1637","Last complete LCD frame":"آخرین تصویر کامل LCD","Display text":"متن نمایشگر","UART errors":"خطاهای UART","Online":"متصل","Offline":"قطع‌شده","Not responding":"بدون پاسخ","Waiting":"در انتظار","Offline ({code})":"قطع‌شده ({code})","Row {row}":"ردیف {row}","Select row {row}":"انتخاب ردیف {row}","{state} · {count} cells":"{state} · {count} خانه","Static":"ثابت","Effects":"جلوه‌ها","Rainbow test":"آزمایش رنگین‌کمان","LED driver: {driver}":"راه‌انداز LED: {driver}","Static cells: six matching pixels. Rainbow test animates explicitly.":"خانه‌های ثابت: شش پیکسل هم‌رنگ. آزمایش رنگین‌کمان، پویانمایی را فعال می‌کند.","Effects: animated output enabled explicitly.":"جلوه‌ها: خروجی متحرک فعال شده است.","Logical cell {id}":"خانهٔ منطقی {id}","Collapse or expand {panel}":"جمع یا باز کردن {panel}","Physical pixel · 1..480":"پیکسل فیزیکی · ۱ تا ۴۸۰","Physical pixel index from 1 to 480":"شمارهٔ پیکسل فیزیکی از ۱ تا ۴۸۰","Previous pixel":"پیکسل قبلی","Show white dot":"نمایش نقطهٔ سفید","Next pixel":"پیکسل بعدی","One physical pixel white, all others black. Explicitly selects effects mode; no animation. Enable LED output first.":"یک پیکسل فیزیکی سفید و بقیه خاموش می‌شوند. حالت جلوه‌ها بدون پویانمایی انتخاب می‌شود. ابتدا خروجی LED را فعال کنید.","RGB unavailable":"RGB در دسترس نیست","Close cell manager":"بستن مدیریت خانه‌ها","Choose rows or cells, then apply a state and transition to the selection. Ctrl toggles, Shift extends. Selection never changes LEDs.":"ردیف‌ها یا خانه‌ها را انتخاب کنید، سپس وضعیت و گذار را اعمال کنید. Ctrl انتخاب را تغییر می‌دهد و Shift آن را گسترش می‌دهد. انتخاب به‌تنهایی LEDها را تغییر نمی‌دهد.","Edit order · drag before/after a cell; IDs and wiring stay unchanged":"ویرایش ترتیب · خانه را به قبل یا بعد از خانه‌ای بکشید؛ شناسه‌ها و سیم‌کشی تغییر نمی‌کنند","Apply updates all selected cells together.":"اعمال، همهٔ خانه‌های انتخاب‌شده را با هم به‌روزرسانی می‌کند.","Apply to selection":"اعمال به انتخاب","Board display name":"نام نمایشی برد","Hostname":"نام میزبان","Living room table":"میز اتاق نشیمن","Application branding comes from the running firmware. Board names are saved across OTA. Only hostname changes restart Wi-Fi/AP; passwords are unchanged.":"هویت برنامه از میان‌افزار فعال دریافت می‌شود. نام برد پس از OTA حفظ می‌شود. فقط تغییر نام میزبان، وای‌فای و نقطهٔ دسترسی را راه‌اندازی مجدد می‌کند؛ گذرواژه‌ها تغییر نمی‌کنند.","Wi-Fi & Ethernet":"وای‌فای و اترنت","Scan for networks":"جست‌وجوی شبکه‌ها","Scan":"جست‌وجو","Enter SSID or choose a scanned network":"نام شبکه را وارد یا از نتیجهٔ جست‌وجو انتخاب کنید","Station password (re-enter on save)":"گذرواژهٔ ایستگاه","Replacement password · blank keeps stored":"گذرواژهٔ جایگزین · خالی یعنی حفظ مقدار ذخیره‌شده","Automatic IPv4 (DHCP)":"آدرس‌دهی خودکار IPv4 (DHCP)","IPv4 address":"نشانی IPv4","Gateway":"دروازهٔ شبکه","Subnet mask":"ماسک زیرشبکه","Primary DNS":"DNS اصلی","Secondary DNS":"DNS دوم","Leave the password blank to keep it for the unchanged SSID. Explicitly choose Open for a passwordless network. Addressing controls appear only when reported by this firmware.":"برای حفظ گذرواژهٔ همان شبکه، این قسمت را خالی بگذارید. برای شبکهٔ بدون گذرواژه، گزینهٔ شبکهٔ باز را انتخاب کنید. تنظیمات نشانی فقط در صورت پشتیبانی میان‌افزار نمایش داده می‌شوند.","Save & verify Wi-Fi":"ذخیره و تأیید وای‌فای","Cloud links":"ارتباطات ابری","Replace password":"جایگزینی گذرواژه","Clear stored password":"پاک‌کردن گذرواژهٔ ذخیره‌شده","Replace token":"جایگزینی توکن","Clear stored token":"پاک‌کردن توکن ذخیره‌شده","Clear token":"پاک‌کردن توکن","Save & verify cloud":"ذخیره و تأیید ارتباطات ابری","Reconnect cloud links":"اتصال دوبارهٔ ارتباطات ابری","TLS validates endpoints against ISRG Root X1. MQTT commands use //command; status and availability are retained.":"TLS گواهی مقصد را با ISRG Root X1 اعتبارسنجی می‌کند. مسیر فرمان MQTT برابر //command است؛ وضعیت و دسترس‌پذیری نگه‌داری می‌شوند.","Require authentication":"نیاز به احراز هویت","Current password":"گذرواژهٔ کنونی","New username":"نام کاربری جدید","New password":"گذرواژهٔ جدید","Confirm password":"تأیید گذرواژه","Save device access":"ذخیرهٔ دسترسی دستگاه","Saved on the ESP and retained across firmware OTA. Authentication is off unless enabled here. Changes apply to API, WebSocket and Telnet and reconnect all clients. It does not change the Wi-Fi password.":"روی ESP ذخیره و پس از OTA حفظ می‌شود. احراز هویت تا زمانی که اینجا فعال نشود خاموش است. تغییرات روی API، وب‌سوکت و تلنت اعمال می‌شوند و کلاینت‌ها دوباره متصل می‌شوند. گذرواژهٔ وای‌فای تغییر نمی‌کند.","Wired Ethernet":"اترنت سیمی","Ethernet unavailable":"اترنت در دسترس نیست","Enable Ethernet":"فعال‌کردن اترنت","Automatic Ethernet addressing (DHCP)":"آدرس‌دهی خودکار اترنت (DHCP)","Advertise link identity (LLDP)":"اعلام هویت پیوند (LLDP)","Advanced: receive LLDP neighbors (promiscuous Ethernet)":"پیشرفته: دریافت همسایه‌های LLDP (اترنت فراگیر)","Read / discard Ethernet draft":"خواندن و لغو پیش‌نویس اترنت","Save Ethernet and verify":"ذخیره و تأیید اترنت","IPv4 ready · {ip}":"IPv4 آماده · {ip}","Link up · waiting for IPv4":"پیوند برقرار · در انتظار IPv4","Link down":"پیوند قطع است","Firmware notifications · ntfy":"اعلان‌های میان‌افزار · ntfy","Send firmware-update notifications (opt in)":"ارسال اعلان‌های به‌روزرسانی میان‌افزار (اختیاری)","HTTPS server":"سرور HTTPS","Private topic":"موضوع خصوصی","Optional bearer token":"توکن دسترسی اختیاری","Disabled by default. Major stages and results only; flashing never waits for delivery. No ntfy message can trigger an update.":"پیش‌فرض خاموش است. فقط مراحل مهم و نتیجه‌ها ارسال می‌شوند؛ فلش‌کردن منتظر تحویل اعلان نمی‌ماند. هیچ پیام ntfy نمی‌تواند به‌روزرسانی را شروع کند.","Save notifications and verify":"ذخیره و تأیید اعلان‌ها","Onboard semantic status RGB":"چراغ RGB وضعیت روی برد","Separate status pixel · board-controlled animation.":"پیکسل وضعیت مستقل · پویانمایی با کنترل برد.","Onboard pixel GPIO":"پایهٔ پیکسل روی برد","Unassigned":"تخصیص‌نیافته","GPIO48 · common DevKit":"GPIO48 · DevKit رایج","GPIO38 · alternate DevKit":"GPIO38 · DevKit جایگزین","Confirm this is the onboard addressable RGB pin":"تأیید می‌کنم این پایهٔ RGB آدرس‌پذیر روی برد است","Enable semantic status output":"فعال‌کردن خروجی وضعیت معنایی","Status RGB brightness":"روشنایی RGB وضعیت","Read / discard draft":"خواندن و لغو پیش‌نویس","Save onboard RGB":"ذخیرهٔ RGB روی برد","Read settings before editing. Preview shows reported RGB.":"پیش از ویرایش، تنظیمات را بخوانید. پیش‌نمایش، RGB گزارش‌شده را نشان می‌دهد.","Verified remote update":"به‌روزرسانی تأییدشدهٔ راه دور","Manifest HTTPS URL":"نشانی HTTPS مانیفست","Manifest SHA-256":"SHA-256 مانیفست","Endpoint bearer (optional)":"توکن دسترسی مقصد (اختیاری)","Both boards":"هر دو برد","ESP (same-version repair)":"ESP (ترمیم همان نسخه)","AVR (same-version repair)":"AVR (ترمیم همان نسخه)","Download & verify only":"فقط بارگیری و اعتبارسنجی","Refresh job":"تازه‌سازی عملیات","Apply verified job…":"اعمال عملیات تأییدشده…","Cancel job":"لغو عملیات","Exact SHA-256, profile and version checks. Applying preserves and verifies AVR EEPROM. Two-board updates are not atomic across power loss; uncertain outcomes keep Prog armed. No automatic retry.":"SHA-256، پروفایل و نسخه دقیقاً بررسی می‌شوند. EEPROM برد AVR حفظ و تأیید می‌شود. قطع برق ممکن است به‌روزرسانی دو برد را ناقص کند؛ در نتیجهٔ نامعلوم، Prog فعال می‌ماند. تلاش دوبارهٔ خودکار انجام نمی‌شود.","No job observed":"عملیاتی دریافت نشده است","Device & client telemetry":"دورسنجی دستگاه و کلاینت‌ها","Read complete snapshot":"خواندن وضعیت کامل","Firmware vault":"مخزن میان‌افزار","Store AVR image":"ذخیرهٔ تصویر AVR","Back up AVR":"پشتیبان‌گیری از AVR","Program + verify":"برنامه‌ریزی و تأیید","Flash backup":"پشتیبان فلش","ESP self-OTA":"OTA خود ESP","Target FPS":"نرخ فریم هدف","Buffered frames":"فریم‌های بافرشده","Streaming target FPS":"نرخ فریم هدف پخش","Maximum buffered stream frames":"حداکثر فریم‌های بافرشدهٔ پخش","Browser policy · 0 is uncapped · skip stale frames under backpressure. Sends are not measured LED refresh.":"سیاست مرورگر · صفر بدون سقف است · هنگام فشار بافر، فریم‌های قدیمی رد می‌شوند. ارسال‌ها معیار نرخ تازه‌سازی LED نیستند.","No browser stream started":"پخش مرورگر آغاز نشده است","Browser sends":"ارسال‌های مرورگر","Stopped":"متوقف شد","Stopped: peer, capability or connection changed":"متوقف شد: دستگاه، قابلیت یا اتصال تغییر کرد","Stopped: socket buffering is unknown":"متوقف شد: وضعیت بافر سوکت نامشخص است","Stopped: partial frame outcome unknown; not replayed":"متوقف شد: نتیجهٔ فریم ناقص نامشخص است؛ دوباره ارسال نمی‌شود","Stopped: send failed or policy invalid; outcome unknown, not replayed":"متوقف شد: ارسال ناموفق یا سیاست نامعتبر است؛ نتیجه نامشخص است و دوباره ارسال نمی‌شود","Stopped: page owner disposed":"متوقف شد: مالک صفحه پایان یافت","Choose 0–240 FPS and 1–16 buffered frames. 0 means uncapped.":"نرخ فریم ۰ تا ۲۴۰ و بافر ۱ تا ۱۶ فریم را انتخاب کنید. صفر بدون سقف است.","Connect to an acknowledged streaming board first":"ابتدا به بردی با قابلیت پخش تأییدشده متصل شوید","Stream owner disposed":"مالک پخش پایان یافته است","{state} · {frames} frames · rate pending · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · نرخ در انتظار · {skipped} ردشده · {bytes} بایت در صف سوکت","{state} · {frames} frames · {rate} FPS average · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · میانگین {rate} فریم بر ثانیه · {skipped} ردشده · {bytes} بایت در صف سوکت","Dismiss {title}":"بستن {title}","{count} selected":"{count} انتخاب‌شده","This board has not acknowledged atomic cell batches. No per-cell fallback was sent.":"این برد فرمان گروهی یکپارچهٔ خانه‌ها را تأیید نکرده است؛ فرمان‌های جداگانه ارسال نشدند.","Sending one board command for {total} cells; sent writes cannot be cancelled.":"یک فرمان برای {total} خانه به برد ارسال می‌شود؛ فرمان ارسال‌شده قابل لغو نیست.","Device confirmed colors for {total} cells":"رنگ {total} خانه توسط دستگاه تأیید شد","Status LED settings saved on device":"تنظیمات چراغ وضعیت روی دستگاه ذخیره شد","Outcome unknown. The single command may have applied; it was not retried. Refresh and inspect.":"نتیجه نامشخص است. ممکن است فرمان اعمال شده باشد؛ دوباره ارسال نشد. تازه‌سازی و بررسی کنید.","No command was sent. Refresh before a new action.":"فرمانی ارسال نشد. پیش از اقدام بعدی تازه‌سازی کنید.","Pixel groups":"گروه‌های پیکسل","Whole group":"تمام گروه","Customize group":"تعداد سفارشی گروه","Physical group":"گروه فیزیکی","Physical pixels":"پیکسل‌های فیزیکی","Use default":"استفاده از پیش‌فرض","All groups use the default pixel count":"تمام گروه‌ها از تعداد پیکسل پیش‌فرض استفاده می‌کنند","Decrease pixel count":"کاهش تعداد پیکسل","Increase pixel count":"افزایش تعداد پیکسل","Discard correction edits?":"ویرایش‌های اصلاح پیکسل کنار گذاشته شوند؟","Discard pixel group edits?":"ویرایش‌های گروه پیکسل کنار گذاشته شوند؟","Enter a pixel count from 1 to 255":"تعداد پیکسل را از ۱ تا ۲۵۵ وارد کنید","Reload keeps the saved board configuration and discards only these edits.":"بارگذاری مجدد، تنظیمات ذخیره‌شدهٔ برد را نگه می‌دارد و فقط این ویرایش‌ها را کنار می‌گذارد.","Correction confirmation is unavailable. Edits were kept.":"پنجرهٔ تأیید اصلاحات در دسترس نیست. ویرایش‌ها حفظ شدند.","Device session changed. Edits were kept.":"نشست دستگاه تغییر کرد. ویرایش‌ها حفظ شدند.","Decrease pixel":"کاهش شمارهٔ پیکسل","Increase pixel":"افزایش شمارهٔ پیکسل","Decrease offset":"کاهش جابه‌جایی","Increase offset":"افزایش جابه‌جایی","Complete the pixel address":"نشانی پیکسل را کامل کنید","Complete prior rules to locate source":"برای تعیین مبدأ، قواعد قبلی را کامل کنید","Black from prior rule · no logical source":"خاموش‌شده با قاعدهٔ قبلی · بدون مبدأ منطقی","Outside assigned groups":"خارج از گروه‌های تخصیص‌یافته","Logical address not current":"نشانی منطقی به‌روز نیست","Logical address unavailable":"نشانی منطقی در دسترس نیست","Logical cell unavailable":"خانهٔ منطقی در دسترس نیست","Wire":"نشانی خروجی","Raw":"خام","Use selected cursor":"استفاده از مکان‌نمای انتخاب‌شده","Use selected cursor ({pixel})":"استفاده از مکان‌نمای انتخاب‌شده ({pixel})","Wire map":"نگاشت مسیر پیکسل","Applied map":"نگاشت اعمال‌شده","Draft map":"نگاشت پیش‌نویس","Draft pixel mapping":"نگاشت پیش‌نویس پیکسل‌ها","Find physical pixel in wire map":"یافتن پیکسل فیزیکی در نگاشت مسیر","Local preview · not applied":"پیش‌نمایش محلی · اعمال نشده","Complete invalid values to preview the draft":"برای پیش‌نمایش پیش‌نویس، مقدارهای نامعتبر را کامل کنید","Applied mapping unavailable":"نگاشت اعمال‌شده در دسترس نیست","No matching pixels":"پیکسل مطابقی پیدا نشد","Advanced probe":"تنظیمات پیشرفتهٔ آزمون پیکسل","Probe effect":"جلوهٔ آزمون پیکسل","Composition":"ترکیب با صحنه","Solid":"ثابت","Flash":"چشمک‌زن","Breathing":"تنفسی","Solo":"مستقل","Overlay":"روی صحنه","Period · ms":"دوره · میلی‌ثانیه","On time · %":"زمان روشن · درصد","Minimum brightness":"کمینهٔ روشنایی","Maximum brightness":"بیشینهٔ روشنایی","Complete diagnostic timing and brightness":"زمان‌بندی و روشنایی آزمون را کامل کنید","Invalid diagnostic options":"تنظیمات آزمون نامعتبر است","Invalid diagnostic timing or brightness":"زمان‌بندی یا روشنایی آزمون نامعتبر است","Minimum brightness exceeds maximum":"کمینهٔ روشنایی از بیشینه بیشتر است","Diagnostic effects were not acknowledged":"پشتیبانی جلوه‌های آزمون تأیید نشده است","Diagnostic overlay was not acknowledged":"پشتیبانی آزمون روی صحنه تأیید نشده است","Board reports probe mask off":"برد خاموشی محدودهٔ آزمون را گزارش می‌کند","Diagnostic frame publication is not confirmed":"انتشار فریم آزمون تأیید نشده است","Rule preview":"پیش‌نمایش قاعده","Send black to this address. Later pixels keep their positions.":"این نشانی خاموش می‌شود و پیکسل‌های بعدی در جای خود می‌مانند.","Skip this value. Following values move one address earlier; the last address is black.":"این مقدار حذف می‌شود؛ مقدارهای بعدی یک نشانی جلو می‌آیند و نشانی آخر خاموش می‌شود.","Move this pixel and all following values. Positive moves later; negative moves earlier. Empty addresses are black.":"این پیکسل و مقدارهای پس از آن جابه‌جا می‌شوند؛ مثبت به عقب و منفی به جلو. نشانی‌های خالی خاموش می‌شوند."}; +const PAKAR_FA={"Disconnected":"قطع‌شده","Hardware":"سخت‌افزار","Network":"شبکه","Pulse":"تپش","Revision":"بازنگری","Import colors…":"ورود رنگ‌ها…","Import cell colors":"ورود رنگ خانه‌ها","Imported":"واردشده","Board sound":"صدای برد","AVR name":"نام AVR","Mutes the physical board buzzer, including welcome and menu sounds. Saves directly on the board; browser sound is separate.":"بیزر خود برد، از جمله صدای خوشامد و منوها را بی‌صدا می‌کند. مستقیم روی برد ذخیره می‌شود؛ صدای مرورگر جدا است.","Appearance is saved in this browser. Board sound changes save directly to the board.":"ظاهر در این مرورگر ذخیره می‌شود. تغییرات صدای برد مستقیم روی برد ذخیره می‌شوند.","Programming safety keeps the board muted.":"حفاظت هنگام برنامه‌ریزی، برد را بی‌صدا نگه می‌دارد.","Verified · saved in EEPROM":"تأیید شد · ذخیره‌شده در EEPROM","Reading board…":"در حال خواندن برد…","LEDs & cells":"LEDها و خانه‌ها","AVR & inputs":"AVR و ورودی‌ها","Firmware & storage":"میان‌افزار و ذخیره‌سازی","Search settings…":"جست‌وجوی تنظیمات…","Close device settings":"بستن تنظیمات دستگاه","Device settings pages":"صفحه‌های تنظیمات دستگاه","Discard & reload…":"لغو تغییرات و خواندن دوباره…","Read the device to load settings":"برای بارگذاری تنظیمات، دستگاه را بخوانید","Only fresh, acknowledged device capabilities are shown. Application preferences are separate from board configuration.":"فقط قابلیت‌های تازه و تأییدشدهٔ دستگاه نمایش داده می‌شوند. تنظیمات برنامه از پیکربندی برد جدا هستند.","Firmware build information · read-only":"اطلاعات ساخت میان‌افزار · فقط خواندنی","UART flow-control mode, pin ownership and hardware layout are build flags, not runtime switches. Rebuild and safely update firmware to change them.":"حالت کنترل جریان UART، تخصیص پایه‌ها و چیدمان سخت‌افزار گزینه‌های زمان ساخت هستند. برای تغییر آن‌ها، میان‌افزار را دوباره بسازید و به‌صورت ایمن به‌روز کنید.","No observed build information":"اطلاعات ساخت دریافت نشده است","Build information not reported by this peer":"این دستگاه اطلاعات ساخت را گزارش نکرده است","Use the same live workspace controls; settings does not duplicate device editors.":"از کنترل‌های زندهٔ فضای کاری استفاده کنید؛ ویرایشگرهای دستگاه در تنظیمات تکرار نمی‌شوند.","Table & mapping":"میز و نگاشت","Text presentation":"نمایش متن","Buzzer & melodies":"بیزر و ملودی‌ها","Waiting for acknowledgment and saved-settings readback…":"در انتظار تأیید و بازخوانی تنظیمات ذخیره‌شده…","Settings changed on another client. Your edits are preserved; discard and reload before saving.":"تنظیمات در کلاینت دیگری تغییر کرده‌اند. ویرایش‌های شما حفظ شده‌اند؛ پیش از ذخیره، تغییرات را لغو کنید و دوباره بخوانید.","Unsaved device edits":"ویرایش‌های ذخیره‌نشدهٔ دستگاه","Observed device settings; no pending edits":"تنظیمات دستگاه دریافت شده‌اند؛ ویرایش ذخیره‌نشده‌ای نیست","Reconnect to read device settings":"برای خواندن تنظیمات دستگاه دوباره متصل شوید","Station SSID":"نام شبکهٔ ایستگاه","Open station network · no password":"شبکهٔ باز ایستگاه · بدون گذرواژه","Station enabled":"ایستگاه فعال","Recovery AP enabled":"نقطهٔ دسترسی بازیابی فعال","Scanned networks":"شبکه‌های یافت‌شده","Enable MQTT":"فعال‌کردن MQTT","Broker hostname":"نام میزبان بروکر","Base topic":"موضوع پایه","Password handling":"مدیریت گذرواژه","Replacement password":"گذرواژهٔ جایگزین","Enable outbound WebSocket":"فعال‌کردن وب‌سوکت خروجی","Server hostname":"نام میزبان سرور","Path":"مسیر","Token handling":"مدیریت توکن","Replacement token":"توکن جایگزین","Telemetry interval · seconds":"فاصلهٔ دورسنجی · ثانیه","Keep stored password":"حفظ گذرواژهٔ ذخیره‌شده","Leave password unset":"بدون گذرواژه","Keep stored token":"حفظ توکن ذخیره‌شده","Leave token unset":"بدون توکن","Wait for the current operation to finish":"منتظر پایان عملیات جاری بمانید","Saved public settings verified by device readback. Secret values remain write-only.":"تنظیمات عمومی با بازخوانی دستگاه تأیید شدند. مقادیر محرمانه فقط نوشتنی می‌مانند.","No acknowledged workspace panels":"پنل تأییدشده‌ای برای فضای کاری نیست","Reset panel layout":"بازنشانی چیدمان پنل‌ها","Show":"نمایش","System":"سیستم","Dark":"تیره","Light":"روشن","Apply":"اعمال","Sign out":"خروج از حساب","Select all":"انتخاب همه","Manage cells…":"مدیریت خانه‌ها…","Toggle without Ctrl":"انتخاب چندتایی بدون Ctrl","Animate cells":"پویانمایی خانه‌ها","LED-state indicators":"نشانگرهای وضعیت LED","Edit order":"ویرایش ترتیب","Reset order":"بازنشانی ترتیب","Cell shape":"شکل خانه","Rounded":"گوشه‌های گرد","Circle · six pixels":"دایره · شش پیکسل","Hexagon · six pixels":"شش‌ضلعی · شش پیکسل","Physical pixel indices":"شمارهٔ پیکسل‌های فیزیکی","Board modes":"حالت‌های برد","Live device":"دستگاه زنده","Clients":"کلاینت‌ها","Station":"ایستگاه","Recovery AP":"نقطهٔ دسترسی بازیابی","Cloud WebSocket":"وب‌سوکت ابری","Cell identity":"هویت خانه","Cell action":"عملیات خانه","Addressable LEDs":"LEDهای آدرس‌پذیر","Save logical / physical mapping":"ذخیرهٔ نگاشت منطقی و فیزیکی","Stream rainbow":"ارسال جریان رنگین‌کمان","Transition":"گذار","Live display":"نمایشگر زنده","Show number":"نمایش عدد","Text":"متن","Scroll short text":"پیمایش متن کوتاه","Repeat":"تکرار","Present":"نمایش متن","Present text":"نمایش متن","Stop text":"توقف متن","Inputs":"ورودی‌ها","Board identity":"هویت برد","Save board identity":"ذخیرهٔ هویت برد","Device access":"دسترسی به دستگاه","Device configuration":"پیکربندی دستگاه","Error":"خطا","Wi-Fi":"وای‌فای","Port":"درگاه","Mapping":"نگاشت","Logical":"خانهٔ منطقی","Logical cell":"خانهٔ منطقی","Physical":"خانهٔ فیزیکی","Find cells: 12, 3–8":"یافتن خانه‌ها: 12, 3–8","Search device settings":"جست‌وجوی تنظیمات دستگاه","Application":"برنامه","Device":"دستگاه","Device settings…":"تنظیمات دستگاه","Device settings":"تنظیمات دستگاه","Preferences…":"تنظیمات…","Table":"میز","Pixel diagnostics":"عیب‌یابی پیکسل‌ها","Pixel corrections":"اصلاح پیکسل‌ها","Hardware monitor":"پایش سخت‌افزار","Events":"رویدادها","Theme":"پوسته","Palette":"پالت رنگ","Control density":"تراکم کنترل‌ها","Compact":"فشرده","Regular":"معمولی","Comfortable":"راحت","Search settings":"جست‌وجوی تنظیمات","Username":"نام کاربری","Password":"گذرواژه","Refresh":"تازه‌سازی","Find cell":"یافتن خانه","Manage cells":"مدیریت خانه‌ها","Current":"کنونی","File":"پرونده","Edit":"ویرایش","Audio":"صدا","Workspace":"فضای کاری","Workspaces":"فضاهای کاری","Restore":"بازیابی","Active":"فعال","Window":"پنجره","Panels":"پنل‌ها","Language":"زبان","Touch feedback":"بازخورد لمسی","System language":"زبان سیستم","English":"انگلیسی","Persian":"فارسی","Direction":"جهت","Automatic":"خودکار","Left to right":"چپ به راست","Right to left":"راست به چپ","Left":"چپ","Right":"راست","Replacement":"جایگزین","Connect":"اتصال","Connecting…":"در حال اتصال…","Disconnect":"قطع اتصال","Identity":"هویت","Order":"ترتیب","Move up":"انتقال به بالا","Move down":"انتقال به پایین","Presentation":"نحوهٔ نمایش","Manage":"مدیریت","Publish":"انتشار","Type":"نوع","Control":"کنترل","Board":"برد","Color palette":"پالت رنگ","Studio":"استودیو","Neutral":"خنثی","New":"جدید","Description":"توضیحات","Overview":"نمای کلی","Capabilities":"قابلیت‌ها","Front panel":"پنل جلویی","Red":"قرمز","Green":"سبز","White":"سفید","Advanced":"پیشرفته","Save to board":"ذخیره در برد","Board name":"نام برد","Not persisted":"ذخیره نشده","Mode":"حالت","Pixel count":"تعداد پیکسل‌ها","pixels":"پیکسل","Single pixel":"یک پیکسل","Rainbow":"رنگین‌کمان","Color":"رنگ","Pixel":"پیکسل","Stop stream":"توقف جریان","Streaming":"در حال پخش","Idle":"آماده","notes":"نت","Unavailable":"در دسترس نیست","Seven-segment display":"نمایشگر هفت‌بخشی","LCD display":"نمایشگر LCD","Controls":"کنترل‌ها","Page":"صفحه","Previous":"قبلی","Next":"بعدی","Decrease":"کاهش","Increase":"افزایش","Select":"انتخاب","Brightness":"روشنایی","Silent mode":"حالت بی‌صدا","No":"خیر","Connection":"اتصال","Reconnect":"اتصال دوباره","Connected":"متصل","Hold":"نگه‌داشتن","Toggle":"تغییر حالت","Segments":"نمایشگر هفت‌بخشی","Protocol":"پروتکل","Ready":"آماده","Box":"جعبه","Column":"ستون","Row":"ردیف","Button":"دکمه","Indicator":"نشانگر","Winner":"برنده","Lost":"بازنده","Transmit":"ارسال","Hide":"پنهان‌کردن","hidden":"پنهان","Lock":"قفل","Visibility":"نمایانی","Physical status RGB":"چراغ RGB فیزیکی","Telemetry":"دورسنجی","Search":"جست‌وجو","Connected board":"برد متصل","Group":"گروه","ON":"روشن","Release":"رهاسازی","Discard":"دور انداختن","Stop preview":"توقف پیش‌نمایش","Preview":"پیش‌نمایش","Close":"بستن","Save":"ذخیره","Preferences":"تنظیمات","Unsaved changes":"تغییرات ذخیره‌نشده","Import":"درون‌ریزی","Export":"برون‌ریزی","Target":"مقصد","Disabled":"غیرفعال","Copy":"رونوشت","Status":"وضعیت","Server":"سرور","Open":"باز کردن","Cancel":"انصراف","Action":"عملکرد","Enabled":"فعال","Sound":"صدا","Appearance":"ظاهر","Pealayer green":"سبز پی‌لیر","Windows blue":"آبی ویندوز","macOS blue":"آبی مک‌اواس","Custom":"سفارشی","Custom accent":"رنگ تأکیدی سفارشی","Reset":"بازنشانی","Icon":"نماد","Presets":"گزینه‌های آماده","Name":"نام","On":"روشن","Off":"خاموش","Actions":"عملیات","cues":"نشانه","selected":"انتخاب‌شده","Source":"منبع","Output":"خروجی","Name:":"نام:","Effect":"جلوه","Play":"پخش","Pause":"مکث","Stop":"توقف","Mute":"قطع صدا","Unknown":"ناشناخته","End":"پایان","Step":"پله‌ای","Time":"زمان","Value":"مقدار","Enable rule":"فعال‌کردن قاعده","Correction rules":"قواعد اصلاح پیکسل","Arrange panels":"چیدمان پنل‌ها","Done":"پایان","Fit":"اندازهٔ پنل","Fit table to panel":"اندازهٔ میز برابر با پنل","Zoom out":"کوچک‌نمایی","Zoom in":"بزرگ‌نمایی","Go to":"رفتن به","Go to panel":"رفتن به پنل","Select {state}":"انتخاب {state}","LED transmitter":"فرستنده LED","GPIO · simple":"GPIO · ساده","RMT + DMA":"RMT + DMA","No active transmitter":"فرستنده فعال نیست","Switching to {backend}…":"در حال تغییر به {backend}…","Active: {backend}":"فعال: {backend}","Transmitter not ready · {backend}":"فرستنده آماده نیست · {backend}","LED transmitter change failed · {error} · active: {backend}":"تغییر فرستنده LED ناموفق بود · {error} · فعال: {backend}","LED transmitter change was accepted, but active state is unconfirmed. No command was replayed.":"تغییر فرستنده LED پذیرفته شد، اما وضعیت فعال تأیید نشده است. فرمان دوباره ارسال نشد.","Device session changed; command was not replayed":"نشست دستگاه تغییر کرد؛ فرمان دوباره ارسال نشد","All panels":"همهٔ پنل‌ها","Read-only":"فقط خواندنی","Lock lost cells":"قفل خانه‌های بازنده","Export JSON":"خروجی JSON","Export CSV":"خروجی CSV","Apply assignments":"اعمال رنگ‌های تخصیص‌یافته","Cell":"خانه","Language & input":"زبان و ورودی","Preferences · This browser":"تنظیمات · این مرورگر","Close appearance":"بستن تنظیمات ظاهر","Apply preferences":"اعمال تنظیمات","Accent":"رنگ تأکیدی","System / default blue":"سیستم / آبی پیش‌فرض","System follows this browser’s light/dark setting.":"حالت سیستم از تنظیم روشن یا تیرهٔ این مرورگر پیروی می‌کند.","Actual shared Pealayer palette definitions.":"پالت‌های رنگ مشترک با پی‌لیر.","System uses blue in this standalone device UI.":"رنگ پیش‌فرض سیستم در این رابط دستگاه، آبی است.","Button text contrast is selected automatically.":"رنگ متن دکمه برای خوانایی، خودکار انتخاب می‌شود.","Silent mode ·":"حالت بی‌صدا ·","Use your configured device credentials.":"مشخصات ورود تنظیم‌شدهٔ دستگاه را وارد کنید.","Sign in":"ورود","Connecting to device…":"در حال اتصال به دستگاه…","Retry connection":"تلاش دوباره برای اتصال","Device access storage is unavailable. USB recovery is required.":"ذخیره‌سازی دسترسی دستگاه در دسترس نیست. بازیابی با USB لازم است.","Connection unavailable. Retry to read the device.":"اتصال در دسترس نیست. برای خواندن دستگاه دوباره تلاش کنید.","Sign in to this device.":"به این دستگاه وارد شوید.","First login: admin / pakarcontroller. Change these in Device access after signing in.":"ورود نخست: admin / pakarcontroller. پس از ورود، این مقادیر را در بخش دسترسی دستگاه تغییر دهید.","AtomicDeploy control surface":"پنل کنترل AtomicDeploy","WebSocket —":"وب‌سوکت —","WebSocket retrying":"تلاش دوباره برای وب‌سوکت","WebSocket live":"وب‌سوکت زنده","Reconnect WebSocket":"اتصال دوبارهٔ وب‌سوکت","Disconnect · use HTTP":"قطع وب‌سوکت · استفاده از HTTP","HTTP refresh · WS off":"تازه‌سازی HTTP · وب‌سوکت خاموش","HTTP status refresh continues. USB transport is managed by the desktop app.":"وضعیت از طریق HTTP تازه‌سازی می‌شود. اتصال USB در برنامهٔ دسکتاپ مدیریت می‌شود.","Panels ▾":"پنل‌ها ▾","Table board":"برد میز","Controller":"کنترلر","Table presentation":"نمایش میز","Game":"بازی","Cell selection":"انتخاب خانه‌ها","First wins":"اولین فشردن برنده است","Waiting for board capabilities…":"در انتظار قابلیت‌های برد…","Capabilities unavailable or stale. Connect to a compatible board.":"قابلیت‌ها در دسترس یا به‌روز نیستند. به برد سازگار متصل شوید.","Read-only: refresh to obtain a supported board/boot identity":"فقط خواندنی: برای دریافت هویت معتبر برد و راه‌اندازی، تازه‌سازی کنید","Top first · CCW · indices 0–479 · sampled RGB before brightness":"شروع از بالا · پادساعت‌گرد · شماره‌های ۰ تا ۴۷۹ · RGB نمونه‌برداری‌شده پیش از اعمال روشنایی","Click selects · Ctrl toggles · Shift extends · drag selects · Enter manages":"کلیک: انتخاب · Ctrl: تغییر انتخاب · Shift: گسترش · کشیدن: انتخاب محدوده · Enter: مدیریت","Find logical cells":"یافتن خانه‌های منطقی","80 logical cells":"۸۰ خانهٔ منطقی","Clear selection":"پاک‌کردن انتخاب","State":"وضعیت","Fade":"تغییر تدریجی","Immediate":"آنی","fade":"تغییر تدریجی","immediate":"آنی","pulse":"تپش","none":"بدون جلوه","rainbow":"رنگین‌کمان","fire":"آتش","Once":"یک بار","Loop":"تکرار پیوسته","Interval":"با فاصله","Buzzer":"بیزر","Play cue":"پخش ملودی","Available melody":"ملودی‌های موجود","Live TM1637 preview":"پیش‌نمایش زندهٔ TM1637","Last complete LCD frame":"آخرین تصویر کامل LCD","Display text":"متن نمایشگر","UART errors":"خطاهای UART","Online":"متصل","Offline":"قطع‌شده","Not responding":"بدون پاسخ","Waiting":"در انتظار","Offline ({code})":"قطع‌شده ({code})","Row {row}":"ردیف {row}","Select row {row}":"انتخاب ردیف {row}","{state} · {count} cells":"{state} · {count} خانه","Static":"ثابت","Effects":"جلوه‌ها","Rainbow test":"آزمایش رنگین‌کمان","LED driver: {driver}":"راه‌انداز LED: {driver}","Effects: animated output enabled explicitly.":"جلوه‌ها: خروجی متحرک فعال شده است.","Logical cell {id}":"خانهٔ منطقی {id}","Collapse or expand {panel}":"جمع یا باز کردن {panel}","Physical pixel · 1..480":"پیکسل فیزیکی · ۱ تا ۴۸۰","Physical pixel index from 1 to 480":"شمارهٔ پیکسل فیزیکی از ۱ تا ۴۸۰","Previous pixel":"پیکسل قبلی","Show white dot":"نمایش نقطهٔ سفید","Next pixel":"پیکسل بعدی","One physical pixel white, all others black. Explicitly selects effects mode; no animation. Enable LED output first.":"یک پیکسل فیزیکی سفید و بقیه خاموش می‌شوند. حالت جلوه‌ها بدون پویانمایی انتخاب می‌شود. ابتدا خروجی LED را فعال کنید.","RGB unavailable":"RGB در دسترس نیست","Close cell manager":"بستن مدیریت خانه‌ها","Choose rows or cells, then apply a state and transition to the selection. Ctrl toggles, Shift extends. Selection never changes LEDs.":"ردیف‌ها یا خانه‌ها را انتخاب کنید، سپس وضعیت و گذار را اعمال کنید. Ctrl انتخاب را تغییر می‌دهد و Shift آن را گسترش می‌دهد. انتخاب به‌تنهایی LEDها را تغییر نمی‌دهد.","Edit order · drag before/after a cell; IDs and wiring stay unchanged":"ویرایش ترتیب · خانه را به قبل یا بعد از خانه‌ای بکشید؛ شناسه‌ها و سیم‌کشی تغییر نمی‌کنند","Apply updates all selected cells together.":"اعمال، همهٔ خانه‌های انتخاب‌شده را با هم به‌روزرسانی می‌کند.","Apply to selection":"اعمال به انتخاب","Board display name":"نام نمایشی برد","Hostname":"نام میزبان","Living room table":"میز اتاق نشیمن","Application branding comes from the running firmware. Board names are saved across OTA. Only hostname changes restart Wi-Fi/AP; passwords are unchanged.":"هویت برنامه از میان‌افزار فعال دریافت می‌شود. نام برد پس از OTA حفظ می‌شود. فقط تغییر نام میزبان، وای‌فای و نقطهٔ دسترسی را راه‌اندازی مجدد می‌کند؛ گذرواژه‌ها تغییر نمی‌کنند.","Wi-Fi & Ethernet":"وای‌فای و اترنت","Scan for networks":"جست‌وجوی شبکه‌ها","Scan":"جست‌وجو","Enter SSID or choose a scanned network":"نام شبکه را وارد یا از نتیجهٔ جست‌وجو انتخاب کنید","Station password (re-enter on save)":"گذرواژهٔ ایستگاه","Replacement password · blank keeps stored":"گذرواژهٔ جایگزین · خالی یعنی حفظ مقدار ذخیره‌شده","Automatic IPv4 (DHCP)":"آدرس‌دهی خودکار IPv4 (DHCP)","IPv4 address":"نشانی IPv4","Gateway":"دروازهٔ شبکه","Subnet mask":"ماسک زیرشبکه","Primary DNS":"DNS اصلی","Secondary DNS":"DNS دوم","Leave the password blank to keep it for the unchanged SSID. Explicitly choose Open for a passwordless network. Addressing controls appear only when reported by this firmware.":"برای حفظ گذرواژهٔ همان شبکه، این قسمت را خالی بگذارید. برای شبکهٔ بدون گذرواژه، گزینهٔ شبکهٔ باز را انتخاب کنید. تنظیمات نشانی فقط در صورت پشتیبانی میان‌افزار نمایش داده می‌شوند.","Save & verify Wi-Fi":"ذخیره و تأیید وای‌فای","Cloud links":"ارتباطات ابری","Replace password":"جایگزینی گذرواژه","Clear stored password":"پاک‌کردن گذرواژهٔ ذخیره‌شده","Replace token":"جایگزینی توکن","Clear stored token":"پاک‌کردن توکن ذخیره‌شده","Clear token":"پاک‌کردن توکن","Save & verify cloud":"ذخیره و تأیید ارتباطات ابری","Reconnect cloud links":"اتصال دوبارهٔ ارتباطات ابری","TLS validates endpoints against ISRG Root X1. MQTT commands use //command; status and availability are retained.":"TLS گواهی مقصد را با ISRG Root X1 اعتبارسنجی می‌کند. مسیر فرمان MQTT برابر //command است؛ وضعیت و دسترس‌پذیری نگه‌داری می‌شوند.","Require authentication":"نیاز به احراز هویت","Current password":"گذرواژهٔ کنونی","New username":"نام کاربری جدید","New password":"گذرواژهٔ جدید","Confirm password":"تأیید گذرواژه","Save device access":"ذخیرهٔ دسترسی دستگاه","Saved on the ESP and retained across firmware OTA. Authentication is off unless enabled here. Changes apply to API, WebSocket and Telnet and reconnect all clients. It does not change the Wi-Fi password.":"روی ESP ذخیره و پس از OTA حفظ می‌شود. احراز هویت تا زمانی که اینجا فعال نشود خاموش است. تغییرات روی API، وب‌سوکت و تلنت اعمال می‌شوند و کلاینت‌ها دوباره متصل می‌شوند. گذرواژهٔ وای‌فای تغییر نمی‌کند.","Wired Ethernet":"اترنت سیمی","Ethernet unavailable":"اترنت در دسترس نیست","Enable Ethernet":"فعال‌کردن اترنت","Automatic Ethernet addressing (DHCP)":"آدرس‌دهی خودکار اترنت (DHCP)","Advertise link identity (LLDP)":"اعلام هویت پیوند (LLDP)","Advanced: receive LLDP neighbors (promiscuous Ethernet)":"پیشرفته: دریافت همسایه‌های LLDP (اترنت فراگیر)","Read / discard Ethernet draft":"خواندن و لغو پیش‌نویس اترنت","Save Ethernet and verify":"ذخیره و تأیید اترنت","IPv4 ready · {ip}":"IPv4 آماده · {ip}","Link up · waiting for IPv4":"پیوند برقرار · در انتظار IPv4","Link down":"پیوند قطع است","Firmware notifications · ntfy":"اعلان‌های میان‌افزار · ntfy","Send firmware-update notifications (opt in)":"ارسال اعلان‌های به‌روزرسانی میان‌افزار (اختیاری)","HTTPS server":"سرور HTTPS","Private topic":"موضوع خصوصی","Optional bearer token":"توکن دسترسی اختیاری","Disabled by default. Major stages and results only; flashing never waits for delivery. No ntfy message can trigger an update.":"پیش‌فرض خاموش است. فقط مراحل مهم و نتیجه‌ها ارسال می‌شوند؛ فلش‌کردن منتظر تحویل اعلان نمی‌ماند. هیچ پیام ntfy نمی‌تواند به‌روزرسانی را شروع کند.","Save notifications and verify":"ذخیره و تأیید اعلان‌ها","Onboard semantic status RGB":"چراغ RGB وضعیت روی برد","Separate status pixel · board-controlled animation.":"پیکسل وضعیت مستقل · پویانمایی با کنترل برد.","Onboard pixel GPIO":"پایهٔ پیکسل روی برد","Unassigned":"تخصیص‌نیافته","GPIO48 · common DevKit":"GPIO48 · DevKit رایج","GPIO38 · alternate DevKit":"GPIO38 · DevKit جایگزین","Confirm this is the onboard addressable RGB pin":"تأیید می‌کنم این پایهٔ RGB آدرس‌پذیر روی برد است","Enable semantic status output":"فعال‌کردن خروجی وضعیت معنایی","Status RGB brightness":"روشنایی RGB وضعیت","Read / discard draft":"خواندن و لغو پیش‌نویس","Save onboard RGB":"ذخیرهٔ RGB روی برد","Read settings before editing. Preview shows reported RGB.":"پیش از ویرایش، تنظیمات را بخوانید. پیش‌نمایش، RGB گزارش‌شده را نشان می‌دهد.","Verified remote update":"به‌روزرسانی تأییدشدهٔ راه دور","Manifest HTTPS URL":"نشانی HTTPS مانیفست","Manifest SHA-256":"SHA-256 مانیفست","Endpoint bearer (optional)":"توکن دسترسی مقصد (اختیاری)","Both boards":"هر دو برد","ESP (same-version repair)":"ESP (ترمیم همان نسخه)","AVR (same-version repair)":"AVR (ترمیم همان نسخه)","Download & verify only":"فقط بارگیری و اعتبارسنجی","Refresh job":"تازه‌سازی عملیات","Apply verified job…":"اعمال عملیات تأییدشده…","Cancel job":"لغو عملیات","Exact SHA-256, profile and version checks. Applying preserves and verifies AVR EEPROM. Two-board updates are not atomic across power loss; uncertain outcomes keep Prog armed. No automatic retry.":"SHA-256، پروفایل و نسخه دقیقاً بررسی می‌شوند. EEPROM برد AVR حفظ و تأیید می‌شود. قطع برق ممکن است به‌روزرسانی دو برد را ناقص کند؛ در نتیجهٔ نامعلوم، Prog فعال می‌ماند. تلاش دوبارهٔ خودکار انجام نمی‌شود.","No job observed":"عملیاتی دریافت نشده است","Device & client telemetry":"دورسنجی دستگاه و کلاینت‌ها","Read complete snapshot":"خواندن وضعیت کامل","Firmware vault":"مخزن میان‌افزار","Store AVR image":"ذخیرهٔ تصویر AVR","Back up AVR":"پشتیبان‌گیری از AVR","Program + verify":"برنامه‌ریزی و تأیید","Flash backup":"پشتیبان فلش","ESP self-OTA":"OTA خود ESP","Target FPS":"نرخ فریم هدف","Buffered frames":"فریم‌های بافرشده","Streaming target FPS":"نرخ فریم هدف پخش","Maximum buffered stream frames":"حداکثر فریم‌های بافرشدهٔ پخش","Browser policy · 0 is uncapped · skip stale frames under backpressure. Sends are not measured LED refresh.":"سیاست مرورگر · صفر بدون سقف است · هنگام فشار بافر، فریم‌های قدیمی رد می‌شوند. ارسال‌ها معیار نرخ تازه‌سازی LED نیستند.","No browser stream started":"پخش مرورگر آغاز نشده است","Browser sends":"ارسال‌های مرورگر","Stopped":"متوقف شد","Stopped: peer, capability or connection changed":"متوقف شد: دستگاه، قابلیت یا اتصال تغییر کرد","Stopped: socket buffering is unknown":"متوقف شد: وضعیت بافر سوکت نامشخص است","Stopped: partial frame outcome unknown; not replayed":"متوقف شد: نتیجهٔ فریم ناقص نامشخص است؛ دوباره ارسال نمی‌شود","Stopped: send failed or policy invalid; outcome unknown, not replayed":"متوقف شد: ارسال ناموفق یا سیاست نامعتبر است؛ نتیجه نامشخص است و دوباره ارسال نمی‌شود","Stopped: page owner disposed":"متوقف شد: مالک صفحه پایان یافت","Choose 0–240 FPS and 1–16 buffered frames. 0 means uncapped.":"نرخ فریم ۰ تا ۲۴۰ و بافر ۱ تا ۱۶ فریم را انتخاب کنید. صفر بدون سقف است.","Connect to an acknowledged streaming board first":"ابتدا به بردی با قابلیت پخش تأییدشده متصل شوید","Stream owner disposed":"مالک پخش پایان یافته است","{state} · {frames} frames · rate pending · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · نرخ در انتظار · {skipped} ردشده · {bytes} بایت در صف سوکت","{state} · {frames} frames · {rate} FPS average · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · میانگین {rate} فریم بر ثانیه · {skipped} ردشده · {bytes} بایت در صف سوکت","Dismiss {title}":"بستن {title}","{count} selected":"{count} انتخاب‌شده","This board has not acknowledged atomic cell batches. No per-cell fallback was sent.":"این برد فرمان گروهی یکپارچهٔ خانه‌ها را تأیید نکرده است؛ فرمان‌های جداگانه ارسال نشدند.","Sending one board command for {total} cells; sent writes cannot be cancelled.":"یک فرمان برای {total} خانه به برد ارسال می‌شود؛ فرمان ارسال‌شده قابل لغو نیست.","Device confirmed colors for {total} cells":"رنگ {total} خانه توسط دستگاه تأیید شد","Status LED settings saved on device":"تنظیمات چراغ وضعیت روی دستگاه ذخیره شد","Outcome unknown. The single command may have applied; it was not retried. Refresh and inspect.":"نتیجه نامشخص است. ممکن است فرمان اعمال شده باشد؛ دوباره ارسال نشد. تازه‌سازی و بررسی کنید.","No command was sent. Refresh before a new action.":"فرمانی ارسال نشد. پیش از اقدام بعدی تازه‌سازی کنید.","Pixel groups":"گروه‌های پیکسل","Whole group":"تمام گروه","Customize group":"تعداد سفارشی گروه","Physical group":"گروه فیزیکی","Physical pixels":"پیکسل‌های فیزیکی","Use default":"استفاده از پیش‌فرض","All groups use the default pixel count":"تمام گروه‌ها از تعداد پیکسل پیش‌فرض استفاده می‌کنند","Decrease pixel count":"کاهش تعداد پیکسل","Increase pixel count":"افزایش تعداد پیکسل","Discard correction edits?":"ویرایش‌های اصلاح پیکسل کنار گذاشته شوند؟","Discard pixel group edits?":"ویرایش‌های گروه پیکسل کنار گذاشته شوند؟","Enter a pixel count from 1 to 255":"تعداد پیکسل را از ۱ تا ۲۵۵ وارد کنید","Reload keeps the saved board configuration and discards only these edits.":"بارگذاری مجدد، تنظیمات ذخیره‌شدهٔ برد را نگه می‌دارد و فقط این ویرایش‌ها را کنار می‌گذارد.","Correction confirmation is unavailable. Edits were kept.":"پنجرهٔ تأیید اصلاحات در دسترس نیست. ویرایش‌ها حفظ شدند.","Device session changed. Edits were kept.":"نشست دستگاه تغییر کرد. ویرایش‌ها حفظ شدند.","Decrease pixel":"کاهش شمارهٔ پیکسل","Increase pixel":"افزایش شمارهٔ پیکسل","Decrease offset":"کاهش جابه‌جایی","Increase offset":"افزایش جابه‌جایی","Complete the pixel address":"نشانی پیکسل را کامل کنید","Complete prior rules to locate source":"برای تعیین مبدأ، قواعد قبلی را کامل کنید","Black from prior rule · no logical source":"خاموش‌شده با قاعدهٔ قبلی · بدون مبدأ منطقی","Outside assigned groups":"خارج از گروه‌های تخصیص‌یافته","Logical address not current":"نشانی منطقی به‌روز نیست","Logical address unavailable":"نشانی منطقی در دسترس نیست","Logical cell unavailable":"خانهٔ منطقی در دسترس نیست","Wire":"نشانی خروجی","Raw":"خام","Use selected cursor":"استفاده از مکان‌نمای انتخاب‌شده","Use selected cursor ({pixel})":"استفاده از مکان‌نمای انتخاب‌شده ({pixel})","Wire map":"نگاشت مسیر پیکسل","Applied map":"نگاشت اعمال‌شده","Draft map":"نگاشت پیش‌نویس","Draft pixel mapping":"نگاشت پیش‌نویس پیکسل‌ها","Find physical pixel in wire map":"یافتن پیکسل فیزیکی در نگاشت مسیر","Local preview · not applied":"پیش‌نمایش محلی · اعمال نشده","Complete invalid values to preview the draft":"برای پیش‌نمایش پیش‌نویس، مقدارهای نامعتبر را کامل کنید","Applied mapping unavailable":"نگاشت اعمال‌شده در دسترس نیست","No matching pixels":"پیکسل مطابقی پیدا نشد","Advanced probe":"تنظیمات پیشرفتهٔ آزمون پیکسل","Probe effect":"جلوهٔ آزمون پیکسل","Composition":"ترکیب با صحنه","Solid":"ثابت","Flash":"چشمک‌زن","Breathing":"تنفسی","Solo":"مستقل","Overlay":"روی صحنه","Period · ms":"دوره · میلی‌ثانیه","On time · %":"زمان روشن · درصد","Minimum brightness":"کمینهٔ روشنایی","Maximum brightness":"بیشینهٔ روشنایی","Complete diagnostic timing and brightness":"زمان‌بندی و روشنایی آزمون را کامل کنید","Invalid diagnostic options":"تنظیمات آزمون نامعتبر است","Invalid diagnostic timing or brightness":"زمان‌بندی یا روشنایی آزمون نامعتبر است","Minimum brightness exceeds maximum":"کمینهٔ روشنایی از بیشینه بیشتر است","Diagnostic effects were not acknowledged":"پشتیبانی جلوه‌های آزمون تأیید نشده است","Diagnostic overlay was not acknowledged":"پشتیبانی آزمون روی صحنه تأیید نشده است","Board reports probe mask off":"برد خاموشی محدودهٔ آزمون را گزارش می‌کند","Diagnostic frame publication is not confirmed":"انتشار فریم آزمون تأیید نشده است","Rule preview":"پیش‌نمایش قاعده","Send black to this address. Later pixels keep their positions.":"این نشانی خاموش می‌شود و پیکسل‌های بعدی در جای خود می‌مانند.","Skip this value. Following values move one address earlier; the last address is black.":"این مقدار حذف می‌شود؛ مقدارهای بعدی یک نشانی جلو می‌آیند و نشانی آخر خاموش می‌شود.","Move this pixel and all following values. Positive moves later; negative moves earlier. Empty addresses are black.":"این پیکسل و مقدارهای پس از آن جابه‌جا می‌شوند؛ مثبت به عقب و منفی به جلو. نشانی‌های خالی خاموش می‌شوند.","Fire direction":"جهت شعله","0° · Up":"۰° · بالا","90° · Right":"۹۰° · راست","180° · Down":"۱۸۰° · پایین","270° · Left":"۲۷۰° · چپ","Static groups: matching pixels. Rainbow and Fire animate when selected.":"گروه‌های ثابت: پیکسل‌های هم‌رنگ. رنگین‌کمان و آتش با انتخاب متحرک می‌شوند."}; // END SHARED PERSIAN DICTIONARY // Actual RayanLamp i18n.tsx normalize/detect/t/digits/controller contracts (MIT). // Keep Pakar's primary-language policy, explicit direction and peer identity. diff --git a/assets/web/pixel-groups.js b/assets/web/pixel-groups.js index 3acc7274..5076cac9 100644 --- a/assets/web/pixel-groups.js +++ b/assets/web/pixel-groups.js @@ -59,15 +59,20 @@ const key=owner,record=model;if(!ready()||reloadPrompt)return;requireCurrent(key); if(record.dirty){ if(!root.WebUI?.dialog)throw Error('Group confirmation is unavailable. Edits were kept.'); - const approved=await new Promise(resolve=>{ + await new Promise(resolve=>{ const prompt=root.WebUI.dialog({title:translate('Discard pixel group edits?'),closeLabel:translate('Close'), description:translate('Reload keeps the saved board configuration and discards only these edits.'), actions:[{label:translate('Cancel'),icon:'close',autofocus:true}, - {label:translate('Reload'),icon:'refresh',variant:'primary',action:()=>resolve(true)}]}); + {label:translate('Reload'),icon:'refresh',variant:'primary',action:async()=>{ + if(reloadPrompt!==prompt||!current(key)||model!==record||!ready())return false; + // Retain modal focus during the read; close only after the + // originating Reload control is enabled again. + await load();resolve(true); + }}]}); reloadPrompt=prompt; prompt.element.addEventListener('close',()=>{if(reloadPrompt===prompt)reloadPrompt=null;resolve(false);},{once:true}); }); - if(!approved)return; + return; } requireCurrent(key);if(model!==record)throw Error('Device session changed. Edits were kept.'); await load(); diff --git a/assets/web/user-files.js b/assets/web/user-files.js new file mode 100644 index 00000000..f6ee5a53 --- /dev/null +++ b/assets/web/user-files.js @@ -0,0 +1,71 @@ +// Canonical Web file workspace. Adapted Pealayer ServerFilePicker's bounded +// listing, filter, explicit actions and stale-request suppression; no media paths. +(function(root){ + 'use strict'; + const hex=bytes=>Array.from(bytes,n=>n.toString(16).padStart(2,'0')).join(''); + const unhex=value=>{if(typeof value!=='string'||value.length%2||!/^[0-9a-f]*$/.test(value))throw Error('Invalid file bytes');return Uint8Array.from(value.match(/../g)||[],n=>parseInt(n,16));}; + // SHA-256 also works on an ESP's plain-HTTP origin (SubtleCrypto need not). + // No passwords/signatures are handled here; this is transfer byte integrity. + function sha256(bytes){ + const k=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; + const h=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; + const data=new Uint8Array(Math.ceil((bytes.length+9)/64)*64);data.set(bytes);data[bytes.length]=128; + const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(bytes.length/0x20000000));view.setUint32(data.length-4,bytes.length*8); + const r=(n,s)=>(n>>>s)|(n<<(32-s)),w=new Uint32Array(64); + for(let offset=0;offset>>3))+w[i-7]+(r(b,17)^r(b,19)^(b>>>10));} + let [a,b,c,d,e,f,g,z]=h; + for(let i=0;i<64;i++){const t=(z+(r(e,6)^r(e,11)^r(e,25))+((e&f)^(~e&g))+k[i]+w[i])|0,u=((r(a,2)^r(a,13)^r(a,22))+((a&b)^(a&c)^(b&c)))|0;z=g;g=f;f=e;e=(d+t)|0;d=c;c=b;b=a;a=(t+u)|0;} + [a,b,c,d,e,f,g,z].forEach((n,i)=>h[i]=(h[i]+n)|0); + } + return h.map(n=>(n>>>0).toString(16).padStart(8,'0')).join(''); + } + const validName=name=>typeof name==='string'&&/^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$/.test(name)&&!name.includes('..'); + // PAX mtime retains original millisecond precision, without altering payload. + function archive(entry,bytes){ + if(!validName(entry.name)||bytes.length!==entry.size||!Number.isSafeInteger(entry.modified_ms)||entry.modified_ms<0||sha256(bytes)!==entry.sha256)throw Error('Invalid archive metadata or bytes'); + const enc=new TextEncoder(),chunks=[]; + function add(name,payload,type,mtime){const header=new Uint8Array(512);const text=(offset,s)=>header.set(enc.encode(s),offset),oct=(offset,len,n)=>text(offset,Math.floor(n).toString(8).padStart(len-1,'0')+'\0'); + text(0,name);oct(100,8,420);oct(108,8,0);oct(116,8,0);oct(124,12,payload.length);oct(136,12,Math.min(mtime,0x1ffffffff));text(148,' ');text(156,type);text(257,'ustar\0');text(263,'00');oct(148,7,header.reduce((a,b)=>a+b,0));header[155]=32; + chunks.push(header,payload,new Uint8Array((512-payload.length%512)%512));} + const value='mtime='+(entry.modified_ms/1000).toFixed(3)+'\n';let length=value.length+3;while(length!==String(length).length+1+value.length)length=String(length).length+1+value.length; + add('PaxHeader',enc.encode(length+' '+value),'x',Math.floor(entry.modified_ms/1000));add(entry.name,bytes,'0',Math.floor(entry.modified_ms/1000));chunks.push(new Uint8Array(1024));return new Blob(chunks,{type:'application/x-tar'}); + } + function create(o){ + const {body,node,button,select,api,pin,snapshot,peer}=o; + let disposed=false,busy=false,generation=0,owner='',files=[],revision=0,loaded=false,prompt=null; + const volume=select([['littlefs','LittleFS'],['sd','microSD']]),filter=node('input'),rows=node('div','file-browser-list'),notice=node('p','status-note'),progress=node('progress'); + filter.type='search';filter.placeholder='Filter files';filter.setAttribute('aria-label','Filter files');progress.max=1;progress.value=0;progress.hidden=true;notice.setAttribute('role','status'); + const picker=node('input');picker.type='file';picker.hidden=true; + const key=()=>{const s=snapshot();return s?.board_id&&s?.boot_id?s.board_id+':'+s.boot_id:'';}; + const assertCurrent=ctx=>{if(disposed||ctx.generation!==generation||ctx.owner!==key()||ctx.volume!==volume.value||!peer().has('managed-files'))throw Error('Device session changed; transfer stopped');}; + const context=()=>({generation,owner:key(),volume:volume.value,peer:pin()}); + async function call(ctx,action,params={}){assertCurrent(ctx);const s=snapshot();const result=await api('/api/maintenance/request',{method:'POST',peer:ctx.peer,headers:{'Content-Type':'application/json'},body:JSON.stringify({board_id:s.board_id,boot_id:s.boot_id,method:'POST',path:'/api/storage/files',body:{volume:ctx.volume,action,...params}})});assertCurrent(ctx);if(result.accepted!==true)throw Error(result.error||'File operation rejected');return result;} + function sync(){refresh.disabled=busy||!key();upload.disabled=busy||!loaded;volume.disabled=busy;progress.hidden=!busy;} + async function task(run){if(busy)return;busy=true;notice.textContent='';progress.value=0;sync();try{const ctx=context();await run(ctx);}catch(e){notice.textContent=e.message;}finally{busy=false;sync();}} + function draw(){rows.replaceChildren();for(const entry of files.filter(f=>f.name.toLowerCase().includes(filter.value.toLowerCase()))){const row=node('div','tools file-browser-row'),label=node('span','file-browser-name',entry.name),detail=node('span','muted',entry.size+' B · '+new Date(entry.modified_ms).toLocaleString());label.title=entry.name; + const save=archiveMode=>task(async ctx=>{const parts=[];let offset=0;while(offsetURL.revokeObjectURL(url),1000);notice.textContent='Download verified';}); + const download=button('Download',()=>root.WebUI.menu(download,[{label:'Download file',icon:'download',action:()=>save(false)},{label:'Download archive (.tar)',icon:'download',action:()=>save(true)}],{label:'Download options'}),'download'); + download.setAttribute('aria-haspopup','menu');row.append(label,detail,download);rows.append(row);} + if(!rows.children.length)rows.append(node('p','muted','No files'));} + async function list(ctx){let offset=0,found=[],r,seenRevision;do{r=await call(ctx,'list',{offset});if(!Array.isArray(r.files)||r.files.length>8||!Number.isInteger(r.revision)||r.next_offset!==offset+r.files.length||r.more&&!r.files.length||seenRevision!==undefined&&seenRevision!==r.revision)throw Error('File index changed; reload');seenRevision=r.revision;found.push(...r.files);if(found.length>32)throw Error('File index too large');offset=r.next_offset;}while(r.more); + for(const f of found)if(!validName(f.name)||!Number.isInteger(f.size)||f.size<0||f.size>131072||!Number.isSafeInteger(f.modified_ms)||f.modified_ms<0||!/^[0-9a-f]{64}$/.test(f.sha256))throw Error('Invalid file index');files=found;revision=r.revision;loaded=true;draw();} + const refresh=button('Reload',()=>task(list),'refresh'),upload=button('Upload file',()=>picker.click(),'upload'); + picker.onchange=()=>task(async ctx=>{const file=picker.files?.[0];picker.value='';if(!file)return;if(!validName(file.name)||file.size>131072)throw Error('Use a simple filename (letters, numbers, dash, underscore, dot), up to 128 KiB'); + const existing=files.find(f=>f.name===file.name); + if(existing){if(!root.WebUI?.dialog)throw Error('Replacement confirmation is unavailable'); + const approved=await new Promise(resolve=>{prompt=root.WebUI.dialog({title:'Replace '+file.name+'?',description:'The previous saved file will be replaced after byte verification.',actions:[{label:'Cancel',icon:'close',autofocus:true},{label:'Replace',icon:'upload',variant:'primary',action:()=>resolve(true)}]});prompt.element.addEventListener('close',()=>{prompt=null;resolve(false);},{once:true});}); + if(!approved)return;assertCurrent(ctx); + } + const bytes=new Uint8Array(await file.arrayBuffer());assertCurrent(ctx);const hash=sha256(bytes),start=await call(ctx,'begin',{name:file.name,size:bytes.length,sha256:hash,modified_ms:file.lastModified,revision,...(existing?{replace_sha256:existing.sha256}:{})}); + let done=false;try{for(let offset=0;offset{generation++;loaded=false;files=[];draw();void task(list);}; + const tools=node('div','tools row');tools.append(volume,refresh,upload,filter);body.append(tools,picker,progress,notice,rows); + return {update(){const next=key();if(next!==owner){owner=next;generation++;prompt?.close();files=[];loaded=false;draw();}const available=peer().known&&peer().has('managed-files');volume.options[1].disabled=!peer().has('microsd-files');if(!available)o.card.hidden=true;else if(!o.registerPanel)o.card.hidden=false;sync();},dispose(){disposed=true;generation++;prompt?.close();o.card.remove();}}; + } + root.UserFiles={sha256,hex,unhex,validName,archive,create}; +})(typeof window==='undefined'?globalThis:window); diff --git a/assets/web/workspace-studio.js b/assets/web/workspace-studio.js index 87575606..919eed79 100644 --- a/assets/web/workspace-studio.js +++ b/assets/web/workspace-studio.js @@ -38,7 +38,7 @@ function installStudio(){ const sidebar=make('aside','studio-sidebar'),brand=make('div','studio-wordmark'),brandImage=make('img'),brandCopy=make('div');brandImage.src='/app-icon.png';brandImage.width=30;brandImage.height=30;brandImage.alt='';const brandTitle=make('strong'),brandCaption=make('small');brandTitle.textContent='PakarController';copy(brandCaption,'Workspace');brandCopy.append(brandTitle,brandCaption);brand.append(brandImage,brandCopy); const nav=make('nav');nav.id='studioNav';nav.setAttribute('aria-label','Workspace');sidebar.append(brand,nav); const content=make('div','studio-content'),heading=make('div','studio-page-heading'),pageTitle=make('h2'),pageActions=make('div','studio-page-actions');pageTitle.id='studioPageTitle';heading.append(pageTitle,pageActions);content.append(heading); - // Keep runtime insertion anchors alive. renderStateAppearance owns .layout .stack. + // Keep runtime insertion anchors alive. Dynamic panels mount on .layout. for(const node of [...workspace.children])if(node!==top&&node!==layout&&node.id!=='applicationActivity')content.append(node); content.append(layout);const activity=document.getElementById('applicationActivity');if(activity)content.append(activity);workspace.append(sidebar,top,content); const route=studioRoute({location:window.location,history:window.history,onNavigate:id=>navigate(id,false)}); diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 62a4e2ae..6160dc63 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -37,7 +37,7 @@ zip = { version = "9.0.5", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] raw-window-handle = "0.6.2" -windows = { version = "0.62", features = ["Win32_Globalization", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging", "Win32_Security", "Win32_System_Console", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_RemoteDesktop", "Win32_System_Pipes", "Win32_System_IO", "Win32_Storage_FileSystem", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Variant"] } +windows = { version = "0.62", features = ["Win32_NetworkManagement_WiFi", "Win32_NetworkManagement_Ndis", "Win32_Globalization", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_Security", "Win32_System_Console", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_RemoteDesktop", "Win32_System_Pipes", "Win32_System_IO", "Win32_Storage_FileSystem", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Variant"] } [build-dependencies] base64 = "0.23.1" diff --git a/desktop/src/capabilities.rs b/desktop/src/capabilities.rs index 384917c9..3d9e8f38 100644 --- a/desktop/src/capabilities.rs +++ b/desktop/src/capabilities.rs @@ -268,8 +268,10 @@ impl Manifest { } } pub fn allows(&self, path: &str, form: &[(String, String)]) -> bool { + if crate::minigame::path(path) { return self.role == "first-wins" && self.has("command-ack"); } if path == crate::pixel_groups::PATH { return self.has(crate::pixel_groups::FEATURE); } if path == crate::pixel_corrections::PATH { return self.has("pixel-corrections"); } + if path == crate::pixel_corrections::PREVIEW_PATH { return self.has(crate::pixel_corrections::PREVIEW_FEATURE); } let feature = match path { "/api/led/presentation" | "/api/led/preview" => return self.has("leds") && self.has("state-presentation"), "/api/cells/assignments" => { diff --git a/desktop/src/config.rs b/desktop/src/config.rs index 56df5f3f..f3d92c39 100644 --- a/desktop/src/config.rs +++ b/desktop/src/config.rs @@ -208,6 +208,8 @@ pub struct AppConfig { pub cell_order: Vec, pub cell_shape: crate::ui::cell_pixels::Shape, pub pixel_diagnostics: bool, + /// Preview preference only; leases and active preview state never persist. + pub pixel_correction_live_preview: bool, pub status_items: crate::ui::status_bar::Items, } impl Default for AppConfig { @@ -252,6 +254,7 @@ impl Default for AppConfig { cell_order: (1..=80).collect(), cell_shape: Default::default(), pixel_diagnostics: false, + pixel_correction_live_preview: true, status_items: Default::default(), } } diff --git a/desktop/src/device_settings.rs b/desktop/src/device_settings.rs index 75668493..1972a875 100644 --- a/desktop/src/device_settings.rs +++ b/desktop/src/device_settings.rs @@ -541,6 +541,8 @@ pub struct Editor { pub close_confirmation: bool, pub ssids: Vec, pub scan_pending: bool, + pub scan_retry_at: Option, + pub scan_deadline: Option, pub usb_request: Option, pub maintenance_confirmation: Option, pub maintenance_pending: Option, @@ -714,8 +716,20 @@ impl Editor { self.telemetry = Some(value); return Ok(()); } - if path == "/api/wifi/scan" { + if matches!(path, "/api/wifi/scan" | "/api/wifi/scan?results=true") { + if value["running"] == true { + if self.scan_pending { + self.scan_retry_at = + Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); + } + return Ok(()); // Keep the previous visible list while scanning. + } self.scan_pending = false; + self.scan_retry_at = None; + self.scan_deadline = None; + if let Some(error) = value["error"].as_str() { + return Err(format!("Wi-Fi scan failed: {error}")); + } self.ssids = value["networks"] .as_array() .ok_or("Invalid Wi-Fi scan response")? @@ -751,6 +765,23 @@ impl Editor { } Ok(()) } + pub fn scan_poll_due(&mut self, now: std::time::Instant) -> bool { + if !self.scan_pending { + return false; + } + if self.scan_deadline.is_some_and(|deadline| now >= deadline) { + self.scan_pending = false; + self.scan_retry_at = None; + self.scan_deadline = None; + self.message = "Wi-Fi scan timed out; no scan was replayed".into(); + return false; + } + if self.scan_retry_at.is_some_and(|due| now >= due) { + self.scan_retry_at = None; // At most one result read outstanding. + return true; + } + false + } pub fn next_id(&mut self) -> u64 { self.serial = self.serial.wrapping_add(1); self.serial @@ -826,6 +857,37 @@ pub fn usb_payloads(config: &Network, password: &str, open: bool) -> Result, #[serde(default)] game_audio_applied: bool, @@ -260,6 +263,7 @@ enum HttpCommand { Status(Endpoint), Mapping(Endpoint, u64), Configuration(Endpoint, &'static str), + CorrectionPreview(Endpoint, pixel_corrections::PreviewRequest), BoardSilent(Endpoint, u64, board_silent::Change), Post(Endpoint, &'static str, Vec<(String, String)>), CellBatch(Endpoint, cell_batch::Correlation), @@ -305,6 +309,7 @@ impl HttpCommand { Self::Maintenance(_, id, _) => Event::MaintenanceFinished(*id, Err(error)), Self::PixelReadback(_, id, ..) => Event::PixelReadbackFinished(*id, Err(error)), Self::Mapping(_, id) => Event::MappingFailed(*id, error), + Self::CorrectionPreview(_, request) => Event::CorrectionPreviewFinished(request.id, Err(error)), _ if self.diagnostic_source().is_some() => { Event::DiagnosticFailure(self.diagnostic_source().unwrap(), error) } @@ -327,6 +332,7 @@ impl HttpCommand { | Self::Mapping(endpoint, ..) | Self::BoardSilent(endpoint, ..) | Self::Configuration(endpoint, ..) + | Self::CorrectionPreview(endpoint, ..) | Self::AccessChange(endpoint, ..) | Self::NameChange(endpoint, ..) | Self::Settings(endpoint, ..) @@ -350,6 +356,7 @@ impl HttpCommand { Self::Status(endpoint) => Self::Status(replace(endpoint)), Self::Mapping(endpoint, id) => Self::Mapping(replace(endpoint), *id), Self::Configuration(endpoint, path) => Self::Configuration(replace(endpoint), path), + Self::CorrectionPreview(endpoint, request) => Self::CorrectionPreview(replace(endpoint), request.clone()), Self::BoardSilent(endpoint, id, desired) => { Self::BoardSilent(replace(endpoint), *id, *desired) } @@ -473,6 +480,7 @@ enum Event { Mapping(u64, Vec, Option<(String, String)>), MappingFailed(u64, String), Configuration(&'static str, serde_json::Value), + CorrectionPreviewFinished(u64, Result), Accepted(String), BoardSilentFinished(u64, Result), CredentialsUpdated(Endpoint), @@ -527,6 +535,7 @@ impl Event { | Self::MaintenanceFinished(_, Err(_)) | Self::PixelModeFinished(_, Err(_)) | Self::PixelReadbackFinished(_, Err(_)) + | Self::CorrectionPreviewFinished(_, Err(_)) | Self::PixelCommandFinished(_, Err(_)) => false, _ => true, } @@ -577,6 +586,9 @@ fn http_request(client: &Client, command: HttpCommand) -> Result return scoped_http_request(client, &scope, &command) .map(|result| Event::ForScope(scope, Box::new(result))); } + if matches!(command, HttpCommand::CorrectionPreview(..)) { + return Err("Correction preview requires a scoped board owner".into()); + } let mapping_id = match &command { HttpCommand::Mapping(_, id) => *id, _ => 0, @@ -764,6 +776,7 @@ fn http_request(client: &Client, command: HttpCommand) -> Result (endpoint, request, 2_u8) } HttpCommand::Configuration(..) + | HttpCommand::CorrectionPreview(..) | HttpCommand::AccessChange(..) | HttpCommand::BoardSilent(..) | HttpCommand::NameChange(..) => unreachable!(), @@ -901,6 +914,7 @@ fn scoped_http_request( if mutating { let allowed = before.capabilities.as_ref().is_some_and(|c| match command { HttpCommand::Post(_, path, form) => c.allows(path, form), + HttpCommand::CorrectionPreview(..) => c.has(pixel_corrections::PREVIEW_FEATURE), HttpCommand::CellBatch(_, correlation) => { c.allows("/api/cells", &correlation.request.form()) } @@ -927,6 +941,12 @@ fn scoped_http_request( } } let pinned = scope.pin.as_ref().map(peer_http::Pin::client).transpose()?; + if let HttpCommand::CorrectionPreview(endpoint, request) = command { + let peer = scope.pin.as_ref().ok_or("Correction preview requires a board owner")?; + let client = pinned.as_ref().ok_or("Correction preview requires a fenced client")?; + let value = pixel_corrections::send_http_preview(client, endpoint, peer, request, || scope.admit())?; + return Ok(Event::CorrectionPreviewFinished(request.id, Ok(value))); + } if let HttpCommand::BoardSilent(endpoint, id, desired) = command { if !desired.allowed(&before) { return Err("Board settings are unavailable or programming safety is active".into()); @@ -3617,6 +3637,8 @@ struct PakarControllerApp { buzzer_frequency: u16, buzzer_duration: u16, key_layout_edit: bool, + key_selection: ui::cell_table::Selection, + key_trigger_on_click: bool, key_positions: [[f32; 2]; 9], output_mask: u8, device_name: Option, @@ -3808,6 +3830,8 @@ impl PakarControllerApp { buzzer_frequency: 1032, buzzer_duration: 100, key_layout_edit: false, + key_selection: Default::default(), + key_trigger_on_click: false, key_positions: native_workspace::default_key_positions(), output_mask: 0, device_name: None, @@ -4735,6 +4759,9 @@ impl PakarControllerApp { self.receive_configuration(path, value); } } + Event::CorrectionPreviewFinished(id, result) => { + self.pixel_corrections.live.finish(id, result, Instant::now()); + } Event::MaintenanceFinished(id, result) => { if self.device_settings.maintenance_pending == Some(id) { if let Some(progress) = self.firmware_transfer.as_ref() { diff --git a/desktop/src/minigame.rs b/desktop/src/minigame.rs new file mode 100644 index 00000000..3e5ff393 --- /dev/null +++ b/desktop/src/minigame.rs @@ -0,0 +1,934 @@ +//! Board-owned games. Native controls use the imported Pealayer dialog section, +//! setting_row and action geometry; local edits never simulate game state. +use crate::{ + DeviceStatus, + ui::{ + dialog, + dropdown::{ComboBox, DropdownUiExt}, + icons, + }, +}; +use eframe::egui; +use serde_json::{Value, json}; + +pub const CONFIGURE: &str = "/api/minigame/configure"; +pub const CONTROL: &str = "/api/minigame/control"; +pub const INPUT: &str = "/api/minigame/input"; +pub fn path(path: &str) -> bool { + matches!(path, CONFIGURE | CONTROL | INPUT) +} +pub fn snapshot(status: &DeviceStatus) -> Option<&Value> { + status + .capabilities + .as_ref() + .filter(|c| c.role == "first-wins")?; + status.minigame.as_object().map(|_| &status.minigame) +} +pub fn ready(status: &DeviceStatus) -> bool { + snapshot(status).is_some_and(|game| game["ready"] == true) && !status.programming_latch +} +pub fn modes(game: &Value) -> Vec<(&str, &str)> { + let mut modes = Vec::new(); + if let Some(items) = game["available_modes"].as_array() { + for item in items { + if let (Some(id), Some(label)) = (item["id"].as_str(), item["label"].as_str()) { + if !id.is_empty() && !modes.iter().any(|(known, _)| *known == id) { + modes.push((id, label)); + } + } + } + } + modes +} +fn mode_visual(id: &str) -> (&'static str, &'static str) { + match id { + "press-to-win" => ( + icons::HAND_TAP, + "First press wins; reset for the next round", + ), + "illuminated" => ( + icons::TARGET, + "Press the glowing target before its window closes", + ), + "color-match" => ( + icons::PALETTE, + "Cycle button colors to match the indicator colors", + ), + "simon" => ( + icons::MUSIC_NOTE, + "Watch, remember and repeat the light sequence", + ), + "memory-pairs" => (icons::COPY, "Reveal colors and find every matching pair"), + "clear-all" => ( + icons::LIST_CHECKS, + "Press every illuminated button to clear the board", + ), + "color-order" => (icons::LIST, "Press the boxes in palette color order"), + "switch-pattern" => ( + icons::GRID_FOUR, + "Toggle buttons to copy the indicator pattern", + ), + "reaction-chase" => ( + icons::LIGHTNING, + "Catch the moving light inside its response window", + ), + "toggle" => ( + icons::TOGGLE_RIGHT, + "Control latched or momentary outputs without a timer", + ), + _ => (icons::PLAY, "Board-provided game"), + } +} +pub fn settings(text: &str, game: &Value) -> Result { + let patch: Value = serde_json::from_str(text).map_err(|_| "Invalid game settings")?; + let fields = patch + .as_object() + .filter(|p| !p.is_empty()) + .ok_or("Empty game settings")?; + for (name, value) in fields { + let valid = match name.as_str() { + "mode" => value + .as_str() + .is_some_and(|id| modes(game).iter().any(|(known, _)| *known == id)), + "hard" | "intro_enabled" | "color_arpeggio" => value.is_boolean(), + "toggle_kind" => value + .as_str() + .is_some_and(|v| matches!(v, "toggle" | "latch" | "press")), + "outputs" => value.as_array().is_some_and(|rules| { + rules.len() == 8 + && rules.iter().all(|rule| { + let key = rule["key"].as_u64(); + match rule["source"].as_str() { + Some("disabled" | "active" | "success" | "failure") => key == Some(0), + Some("held" | "latched" | "winner" | "lost") => { + key.is_some_and(|k| (1..=9).contains(&k)) + } + _ => false, + } + }) + }), + field => value.as_u64().is_some_and(|v| { + bounds(field).is_some_and(|(min, max, _)| (min..=max).contains(&v)) + }), + }; + if !valid { + return Err(format!("Invalid game setting: {name}")); + } + } + let get = |name: &str| patch.get(name).unwrap_or(&game["settings"][name]).as_u64(); + if matches!((get("urgent_ms"),get("warning_ms")),(Some(urgent),Some(warning)) if urgent>warning) + { + return Err("Urgent time must not exceed warning time".into()); + } + Ok(patch) +} +fn bounds(field: &str) -> Option<(u64, u64, &'static str)> { + Some(match field { + "limit_ms" => (0, 86400000, "Time limit (0 = untimed)"), + "warning_ms" => (0, 86400000, "Warning"), + "urgent_ms" => (0, 86400000, "Urgent warning"), + "penalty_ms" => (0, 86400000, "Mistake penalty"), + "window_ms" => (100, 60000, "Response window"), + "show_ms" => (100, 10000, "Show duration"), + "gap_ms" => (50, 5000, "Sequence gap"), + "feedback_ms" => (50, 5000, "Feedback duration"), + "intro_ms" => (100, 10000, "Reveal fade per box"), + "preview_ms" => (0, 10000, "Pairs preview hold"), + "mismatch_ms" => (600, 10000, "Wrong pair feedback"), + "rounds" => (1, 32, "Rounds"), + "sequence_length" => (1, 32, "Sequence length"), + "palette_size" => (2, 9, "Palette size"), + "fixed_target" => (0, 9, "Target box (0 = random)"), + _ => return None, + }) +} +pub fn validate( + status: &DeviceStatus, + path: &str, + form: &[(String, String)], +) -> Result<(), String> { + if !ready(status) { + return Err("Game hardware is not ready; nothing sent".into()); + } + let get = |key: &str| form.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()); + match path { + CONFIGURE if form.len() == 1 && get("settings").is_some() => { + settings(get("settings").unwrap(), &status.minigame)?; + } + CONTROL + if form.len() == 1 + && get("action").is_some_and(|v| { + matches!(v, "start" | "pause" | "resume" | "reset" | "stop") + }) => {} + INPUT + if form.len() == 2 + && get("key") + .is_some_and(|v| v.parse::().is_ok_and(|k| (1..=9).contains(&k))) + && get("action").is_some_and(|v| matches!(v, "tap" | "down" | "up")) => {} + _ => return Err("Invalid game command".into()), + } + Ok(()) +} +pub fn verify(path: &str, form: &[(String, String)], status: &DeviceStatus) -> bool { + let Some(game) = snapshot(status) else { + return false; + }; + if game["revision"].as_u64().is_none() || game["phase"].as_str().is_none() { + return false; + } + let get = |key: &str| { + form.iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + .unwrap_or("") + }; + match path { + CONFIGURE => { + serde_json::from_str::(get("settings")) + .ok() + .and_then(|patch| patch.as_object().cloned()) + .is_some_and(|fields| fields.iter().all(|(k, v)| game["settings"][k] == *v)) + && game["phase"] == "ready" + } + CONTROL => match get("action") { + "reset" => game["phase"] == "ready", + "stop" => game["phase"] == "stopped", + "pause" => game["phase"] == "paused", + "start" | "resume" => { + game["phase"] != "ready" && game["phase"] != "paused" && game["phase"] != "stopped" + } + _ => false, + }, + // Inputs can complete a round immediately. The correlated accepted receipt + // and its authoritative revision are proof; never invent a local winner. + INPUT => true, + _ => false, + } +} + +#[derive(Clone)] +struct Draft { + base: Value, + edited: Value, +} +pub struct Request { + pub path: &'static str, + pub form: Vec<(String, String)>, +} +pub struct UsbRequest { + request: Request, + id: String, + peer: crate::peer_http::Pin, + revision: u64, + pub stamp: crate::connection::Stamp, +} +impl UsbRequest { + pub fn prepare( + status: &DeviceStatus, + request: Request, + stamp: crate::connection::Stamp, + ) -> Result<(Self, Value), String> { + validate(status, request.path, &request.form)?; + let peer = crate::peer_http::Pin::from_status(status).ok_or("USB game peer unavailable")?; + if !stamp.current() { + return Err("USB game connection retired".into()); + } + let revision = status.minigame["revision"] + .as_u64() + .ok_or("Game revision unavailable")?; + let expires = status + .command_clock_ms + .and_then(|v| v.checked_add(8000)) + .ok_or("Game command clock unavailable")?; + let id = uuid::Uuid::new_v4().simple().to_string(); + let command = match request.path { + CONFIGURE => "minigame-configure", + CONTROL => "minigame-control", + _ => "minigame-input", + }; + let mut packet = json!({"type":"command","request_id":id,"board_id":status.board_id,"boot_id":status.boot_id,"expires_at_ms":expires,"command":command}); + for (key, value) in &request.form { + packet[key] = match key.as_str() { + "settings" => settings(value, &status.minigame)?, + "key" => json!(value.parse::().map_err(|_| "Invalid key")?), + _ => json!(value), + }; + } + Ok(( + Self { + request, + id, + peer, + revision, + stamp, + }, + packet, + )) + } + pub fn verify(&self, value: Value) -> Result { + if !self.stamp.current() + || value["type"] != "command-result" + || value["request_id"] != self.id + || !self.peer.matches_identity( + value["board_id"].as_str().unwrap_or(""), + value["boot_id"].as_str().unwrap_or(""), + ) + { + return Err("USB game owner or receipt changed; nothing replayed".into()); + } + if value["accepted"] != true { + return Err(format!( + "Game rejected: {}", + value["reason"] + .as_str() + .or(value["error"].as_str()) + .unwrap_or("unknown") + )); + } + let status: DeviceStatus = serde_json::from_value(value["status"].clone()) + .map_err(|_| "Missing game status readback")?; + if crate::peer_http::Pin::from_status(&status).as_ref() != Some(&self.peer) + || status.minigame["revision"] + .as_u64() + .is_none_or(|revision| revision < self.revision) + || !verify(self.request.path, &self.request.form, &status) + { + return Err("USB game readback differs; nothing replayed".into()); + } + if status.compatibility.is_some() { + crate::peer_identity::validate_board(&status)?; + } + Ok(status) + } +} +pub fn input(key: u8, action: &str) -> Request { + Request { + path: INPUT, + form: vec![ + ("key".into(), key.to_string()), + ("action".into(), action.into()), + ], + } +} +pub fn input_action(ui: &egui::Ui, status: &DeviceStatus) -> String { + if !hold_controls(status) { + return "tap".into(); + } + ui.data(|data| { + data.get_temp::(egui::Id::new(( + "minigame-input", + &status.board_id, + &status.boot_id, + status.minigame["mode"].as_str().unwrap_or(""), + ))) + }) + .unwrap_or_else(|| "tap".into()) +} +pub fn controls(ui: &mut egui::Ui, status: &DeviceStatus, writable: bool) -> Vec { + panel(ui, status, writable, false) +} +pub fn settings_controls(ui: &mut egui::Ui, status: &DeviceStatus, writable: bool) -> Vec { + panel(ui, status, writable, true) +} +pub fn shows_elapsed(mode: &str) -> bool { + !matches!(mode, "press-to-win" | "toggle") +} +fn setting_visible(mode: &str, field: &str, timed: bool) -> bool { + match field { + "limit_ms" | "warning_ms" | "urgent_ms" => timed, + "penalty_ms" => timed && mode == "color-order", + "window_ms" | "fixed_target" => matches!(mode, "illuminated" | "reaction-chase"), + "show_ms" | "gap_ms" | "sequence_length" => mode == "simon", + "intro_ms" => matches!(mode, "color-match" | "memory-pairs"), + "preview_ms" | "mismatch_ms" => mode == "memory-pairs", + "rounds" => matches!(mode, "illuminated" | "reaction-chase" | "simon"), + "palette_size" => matches!(mode, "color-match" | "memory-pairs" | "color-order"), + "feedback_ms" => !matches!(mode, "press-to-win" | "toggle"), + _ => true, + } +} +pub fn input_controls(ui: &mut egui::Ui, status: &DeviceStatus) { + if !hold_controls(status) { + return; + } + let id = egui::Id::new(( + "minigame-input", + &status.board_id, + &status.boot_id, + status.minigame["mode"].as_str().unwrap_or(""), + )); + let mut action = input_action(ui, status); + ui.horizontal_wrapped(|ui| { + ui.weak("On click"); + for (value, label) in [("tap", "Tap"), ("down", "Hold"), ("up", "Release")] { + if crate::ui::effect_controls::choice(ui, icons::HAND_TAP, label, action == value, true) + .clicked() + { + action = value.into(); + } + } + }); + ui.data_mut(|data| data.insert_temp(id, action)); +} +fn hold_controls(status: &DeviceStatus) -> bool { + status.minigame["mode"] == "color-match" + || (status.minigame["mode"] == "toggle" + && status.minigame["settings"]["toggle_kind"] == "press") +} +fn panel( + ui: &mut egui::Ui, + status: &DeviceStatus, + writable: bool, + settings_only: bool, +) -> Vec { + let Some(game) = snapshot(status) else { + return Vec::new(); + }; + let mut requests = Vec::new(); + let enabled = writable && ready(status); + dialog::section( + ui, + if settings_only { + icons::GEAR + } else { + icons::PLAY + }, + if settings_only { + "Rules & timing" + } else { + "Game" + }, + |ui| { + let phase = game["phase"].as_str().unwrap_or("Unknown"); + let mode = game["mode"].as_str().unwrap_or(""); + let catalog = modes(game); + if !settings_only { + ui.horizontal_wrapped(|ui| { + ui.strong( + catalog + .iter() + .find(|(id, _)| *id == mode) + .map(|(_, label)| *label) + .unwrap_or(mode), + ); + let color = match phase { + "success" => ui.visuals().selection.bg_fill, + "failure" | "timeout" => ui.visuals().error_fg_color, + _ => ui.visuals().text_color(), + }; + ui.colored_label(color, format!("{} {}", icons::DOT_OUTLINE, phase)); + if game["timed"] != true { + ui.weak("Untimed"); + } + if let Some(winner) = game["winner"].as_u64().filter(|v| *v > 0) { + ui.strong(format!("Winner · Box {winner}")); + } + if let Some(target) = game["target"].as_u64().filter(|v| *v > 0) { + ui.label(format!("Target · Box {target}")); + } + }); + if game["timed"] == true { + if let (Some(remaining), Some(limit)) = ( + game["remaining_ms"].as_u64(), + game["settings"]["limit_ms"].as_u64(), + ) { + let fraction = if limit > 0 { + (remaining as f32 / limit as f32).clamp(0.0, 1.0) + } else { + 0.0 + }; + let mut bar = egui::ProgressBar::new(fraction).text(format!( + "{}:{:02}.{:01} remaining", + remaining / 60000, + remaining / 1000 % 60, + remaining / 100 % 10 + )); + if game["warning"].as_u64().unwrap_or(0) > 0 { + bar = bar.fill(ui.visuals().warn_fg_color); + } + ui.add(bar); + } + } + ui.horizontal_wrapped(|ui| { + for (field, label) in [ + ("round", "Round"), + ("progress", "Progress"), + ("mistakes", "Mistakes"), + ] { + if shows_elapsed(mode) + && let Some(n) = game[field].as_u64() + { + ui.weak(format!("{label} {n}")); + } + } + if shows_elapsed(mode) + && let Some(ms) = game["elapsed_ms"].as_u64() + { + ui.weak(format!("Elapsed {}:{:02}", ms / 60000, ms / 1000 % 60)); + } + for (field, label) in + [("last_reaction_ms", "Last"), ("best_reaction_ms", "Best")] + { + if let Some(ms) = game[field].as_u64().filter(|v| *v > 0) { + ui.weak(format!("{label} {ms} ms")); + } + } + }); + ui.horizontal_wrapped(|ui| { + for (field, label) in [ + ("participating_mask", "Playing"), + ("held_mask", "Held"), + ("latched_mask", "Latched"), + ] { + if let Some(mask) = game[field].as_u64() { + let keys: Vec = (1..=9) + .filter(|key| mask & (1 << (key - 1)) != 0) + .map(|key| key.to_string()) + .collect(); + if !keys.is_empty() { + ui.weak(format!("{label}: {}", keys.join(", "))); + } + } + } + }); + if !ready(status) { + ui.colored_label( + ui.visuals().warn_fg_color, + "Waiting for game hardware readiness", + ); + } + ui.add_enabled_ui(enabled, |ui| { + let running = matches!(phase, "show" | "input" | "feedback"); + let (primary_action, primary_label) = if running { + ("pause", "Pause") + } else if phase == "paused" { + ("resume", "Resume") + } else { + ("start", "Start") + }; + // Reuse the measured scoped-action helper: horizontal_wrapped cannot + // wrap dialog scopes after their minimum width is discovered. + crate::ui::panel_details::action_row( + ui, + &[ + ( + if running { icons::PAUSE } else { icons::PLAY }, + primary_label, + ), + (icons::ARROW_COUNTER_CLOCKWISE, "Reset to white"), + (icons::STOP, "Stop"), + ], + |ui| { + for (action, label, icon, allowed) in [ + ( + primary_action, + primary_label, + if running { icons::PAUSE } else { icons::PLAY }, + true, + ), + ( + "reset", + "Reset to white", + icons::ARROW_COUNTER_CLOCKWISE, + true, + ), + ("stop", "Stop", icons::STOP, phase != "stopped"), + ] { + if ui + .add_enabled_ui(allowed, |ui| { + if action == "reset" { + dialog::primary_action_button(ui, icon, label) + } else { + dialog::action_button(ui, icon, label) + } + }) + .inner + .clicked() + { + requests.push(Request { + path: CONTROL, + form: vec![("action".into(), action.into())], + }); + } + } + }, + ); + }); + } + if settings_only { + ui.add_enabled_ui(enabled, |ui| { + let id = + egui::Id::new(("minigame-settings", &status.board_id, &status.boot_id)); + let current = &game["settings"]; + if !current.is_object() { + ui.weak("Settings unavailable"); + return; + } + let mut draft = + ui.data(|data| data.get_temp::(id)) + .unwrap_or_else(|| Draft { + base: current.clone(), + edited: current.clone(), + }); + if draft.edited == *current || draft.edited == draft.base { + draft = Draft { + base: current.clone(), + edited: current.clone(), + }; + } + let stale = draft.base != *current; + if stale { + ui.colored_label( + ui.visuals().warn_fg_color, + "Board settings changed. Revert draft to reload before applying.", + ); + } + let mut selected = draft.edited["mode"].as_str().unwrap_or(mode).to_owned(); + ComboBox::from_id_salt("minigame-mode") + .selected_text( + catalog + .iter() + .find(|(id, _)| *id == selected) + .map(|(_, label)| format!("{} {label}", mode_visual(&selected).0)) + .unwrap_or_else(|| selected.clone()), + ) + .width(ui.available_width().min(430.0)) + .height(460.0) + .show_ui(ui, |ui| { + for (id, label) in &catalog { + let (icon, fallback) = mode_visual(id); + let detail = game["available_modes"] + .as_array() + .and_then(|items| items.iter().find(|item| item["id"] == *id)) + .and_then(|item| item["description"].as_str()) + .unwrap_or(fallback); + let response = dialog::navigation_detail_button( + ui, + selected == *id, + icon, + label, + detail, + ui.available_width(), + ); + let response = crate::ui::dropdown::register_choice( + ui, + response, + selected == *id, + ); + if response.clicked() { + selected = (*id).to_owned(); + ui.close(); + } + } + }); + if draft.edited["mode"] != selected { + draft.edited["limit_ms"] = json!(if matches!( + selected.as_str(), + "press-to-win" | "toggle" | "clear-all" + ) { + 0 + } else { + 60000 + }); + draft.edited["window_ms"] = json!(if selected == "illuminated" { + 10000 + } else { + 1200 + }); + } + draft.edited["mode"] = json!(selected); + let mut timed = draft.edited["limit_ms"].as_u64().unwrap_or(0) > 0; + if ui.checkbox(&mut timed, "Time limit").changed() { + draft.edited["limit_ms"] = json!(if timed { 60000 } else { 0 }); + } + for field in [ + "limit_ms", + "warning_ms", + "urgent_ms", + "penalty_ms", + "window_ms", + "show_ms", + "gap_ms", + "feedback_ms", + "intro_ms", + "preview_ms", + "mismatch_ms", + "rounds", + "sequence_length", + "palette_size", + "fixed_target", + ] { + if !setting_visible(&selected, field, timed) { + continue; + } + if let Some(mut number) = draft.edited[field].as_u64() { + let (min, max, label) = bounds(field).unwrap(); + dialog::setting_row(ui, icons::SLIDERS_HORIZONTAL, label, None, |ui| { + let suffix = if field.ends_with("_ms") { " ms" } else { "" }; + if ui + .add( + egui::DragValue::new(&mut number) + .range(min..=max) + .suffix(suffix), + ) + .changed() + { + draft.edited[field] = json!(number); + } + }); + } + } + for (field, label) in [ + ("intro_enabled", "Reveal colors and notes before play"), + ("color_arpeggio", "Arpeggio color notes"), + ] { + if field == "intro_enabled" + && !matches!(selected.as_str(), "color-match" | "memory-pairs") + { + continue; + } + if let Some(mut value) = draft.edited[field].as_bool() { + if ui.checkbox(&mut value, label).changed() { + draft.edited[field] = json!(value); + } + } + } + if matches!(selected.as_str(), "switch-pattern" | "color-match") + && let Some(mut hard) = draft.edited["hard"].as_bool() + { + if ui.checkbox(&mut hard, "Hard mode").changed() { + draft.edited["hard"] = json!(hard); + } + } + if selected == "toggle" + && let Some(kind) = draft.edited["toggle_kind"].as_str() + { + let mut kind = kind.to_owned(); + ui.horizontal_wrapped(|ui| { + ui.label("Switch behavior"); + for value in ["toggle", "latch", "press"] { + ui.selectable_value(&mut kind, value.into(), value); + } + }); + draft.edited["toggle_kind"] = json!(kind); + } + ui.collapsing("Output mapping", |ui| { + if let Some(rules) = draft.edited["outputs"].as_array_mut() { + for (index, rule) in rules.iter_mut().enumerate() { + ui.push_id(index, |ui| { + ui.horizontal_wrapped(|ui| { + ui.label(format!("Output {}", index + 1)); + let mut source = rule["source"] + .as_str() + .unwrap_or("disabled") + .to_owned(); + ComboBox::from_id_salt("source") + .selected_text(&source) + .show_ui(ui, |ui| { + for source_id in [ + "disabled", "active", "success", "failure", + "held", "latched", "winner", "lost", + ] { + ui.dropdown_value( + &mut source, + source_id.into(), + source_id, + ); + } + }); + let keyed = matches!( + source.as_str(), + "held" | "latched" | "winner" | "lost" + ); + let mut key = rule["key"].as_u64().unwrap_or(0); + if keyed { + key = key.clamp(1, 9); + ui.add( + egui::DragValue::new(&mut key) + .range(1..=9) + .prefix("Box "), + ); + } else { + key = 0; + } + *rule = json!({"source":source,"key":key}); + }); + }); + } + } + }); + let patch: serde_json::Map = draft + .edited + .as_object() + .unwrap() + .iter() + .filter(|(k, v)| draft.base[*k] != **v) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let patch = Value::Object(patch); + let valid = settings(&patch.to_string(), game); + crate::ui::panel_details::action_row( + ui, + &[ + (icons::CHECK, "Apply & reset"), + (icons::ARROW_COUNTER_CLOCKWISE, "Revert draft"), + ], + |ui| { + if ui + .add_enabled_ui(valid.is_ok() && !stale, |ui| { + dialog::primary_action_button(ui, icons::CHECK, "Apply & reset") + }) + .inner + .clicked() + { + requests.push(Request { + path: CONFIGURE, + form: vec![("settings".into(), patch.to_string())], + }); + } + if dialog::action_button( + ui, + icons::ARROW_COUNTER_CLOCKWISE, + "Revert draft", + ) + .clicked() + { + draft = Draft { + base: current.clone(), + edited: current.clone(), + }; + } + }, + ); + if draft.edited != draft.base { + ui.weak("Unsaved settings · applying returns the game to Ready."); + if let Err(error) = valid { + ui.colored_label(ui.visuals().warn_fg_color, error); + } + } + ui.data_mut(|data| data.insert_temp(id, draft)); + }); + } + }, + ); + requests +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn untimed_press_and_toggle_hide_irrelevant_elapsed_timer() { + assert!(!shows_elapsed("press-to-win")); + assert!(!shows_elapsed("toggle")); + assert!(shows_elapsed("clear-all")); + let ctx = egui::Context::default(); + let mut s = status(); + s.minigame["mode"] = json!("press-to-win"); + s.minigame["timed"] = json!(false); + s.minigame["elapsed_ms"] = json!(12345); + for _ in 0..2 { + let mut output = ctx.run_ui(Default::default(), |ui| { + assert!(controls(ui, &s, true).is_empty()); + assert!(settings_controls(ui, &s, true).is_empty()); + }); + assert!(!format!("{:?}", output.shapes).contains("Elapsed")); + output.textures_delta.clear(); + } + } + fn status() -> DeviceStatus { + serde_json::from_value(json!({"board_id":"fixture","boot_id":"boot", + "capabilities":{"role":"first-wins"},"minigame":{"ready":true,"revision":1, + "mode":"future-game","phase":"ready","available_modes":[{"id":"future-game","label":"Future game"}], + "settings":{"mode":"future-game","warning_ms":10000,"urgent_ms":3000,"rounds":5}}})).unwrap() + } + #[test] + fn minigame_catalog_and_partial_settings_are_board_owned() { + let s = status(); + assert_eq!(modes(&s.minigame), vec![("future-game", "Future game")]); + assert!(settings(r#"{"mode":"future-game"}"#, &s.minigame).is_ok()); + assert!(settings(r#"{"mode":"simon"}"#, &s.minigame).is_err()); + assert!(settings(r#"{"warning_ms":2000}"#, &s.minigame).is_err()); + assert_eq!( + settings(r#"{"rounds":7}"#, &s.minigame).unwrap(), + json!({"rounds":7}) + ); + } + #[test] + fn minigame_presence_readiness_and_input_bounds_fail_closed() { + let mut s = status(); + assert!(validate(&s, INPUT, &input(9, "up").form).is_ok()); + assert!(validate(&s, INPUT, &input(10, "up").form).is_err()); + assert!(validate(&s, INPUT, &input(1, "invalid").form).is_err()); + s.minigame["ready"] = json!(false); + assert!(validate(&s, INPUT, &input(1, "tap").form).is_err()); + s.minigame = Value::Null; + assert!(snapshot(&s).is_none()); + s = status(); + s.capabilities.as_mut().unwrap().role = "table-selection".into(); + assert!(snapshot(&s).is_none()); + } + #[test] + fn minigame_configuration_readback_checks_only_the_requested_fields() { + let mut s = status(); + let form = vec![("settings".into(), r#"{"rounds":7}"#.into())]; + assert!(!verify(CONFIGURE, &form, &s)); + s.minigame["settings"]["rounds"] = json!(7); + assert!(verify(CONFIGURE, &form, &s)); + s.minigame["phase"] = json!("input"); + assert!(!verify(CONFIGURE, &form, &s)); + } + #[test] + fn minigame_panel_open_resize_and_readiness_changes_never_send_commands() { + for width in [280.0, 720.0] { + let ctx = egui::Context::default(); + let mut s = status(); + for ready in [true, false, true] { + s.minigame["ready"] = json!(ready); + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(width, 1000.0), + )), + ..Default::default() + }, + |ui| { + assert!(controls(ui, &s, true).is_empty()); + assert!( + ui.min_rect().right() <= width + 1.0, + "panel overflow at width {width}: {:?}", + ui.min_rect() + ); + }, + ); + output.textures_delta.clear(); + } + } + } + #[test] + fn minigame_usb_packet_and_receipt_are_peer_and_connection_bound() { + let mut s = status(); + s.boot_id = "a".repeat(32); + s.http_peer_fencing = true; + s.command_clock_ms = Some(1000); + let gate = crate::connection::Gate::default(); + let epoch = gate.select(Some(crate::Transport::Serial)); + let (request, packet) = UsbRequest::prepare( + &s, + input(2, "down"), + gate.stamp(crate::Transport::Serial, epoch), + ) + .unwrap(); + assert_eq!(packet["command"], "minigame-input"); + assert_eq!(packet["key"], 2); + assert_eq!(packet["expires_at_ms"], 9000); + let receipt = json!({"type":"command-result","request_id":packet["request_id"],"board_id":s.board_id,"boot_id":s.boot_id,"accepted":true,"status":s}); + assert!(request.verify(receipt.clone()).is_ok()); + let mut wrong = receipt.clone(); + wrong["status"]["minigame"]["revision"] = json!(0); + assert!(request.verify(wrong).is_err()); + gate.select(None); + assert!(request.verify(receipt).is_err()); + } +} diff --git a/desktop/src/native_workspace.rs b/desktop/src/native_workspace.rs index cd546b9a..1eb1eaf4 100644 --- a/desktop/src/native_workspace.rs +++ b/desktop/src/native_workspace.rs @@ -59,6 +59,8 @@ pub struct Snapshot { pub buzzer_frequency: u16, pub buzzer_duration: u16, pub key_positions: [[f32; 2]; 9], + pub key_selection: Vec, + pub key_trigger_on_click: bool, pub settings_page: usize, pub settings_search: String, pub pixel_probe: crate::pixel_diagnostic::Probe, @@ -79,6 +81,8 @@ impl Default for Snapshot { buzzer_frequency: 1032, buzzer_duration: 100, key_positions: default_key_positions(), + key_selection: Vec::new(), + key_trigger_on_click: false, settings_page: 0, settings_search: String::new(), pixel_probe: Default::default(), @@ -99,6 +103,9 @@ impl Snapshot { self.selection.retain(|id| (1..=80).contains(id)); self.selection.sort_unstable(); self.selection.dedup(); + self.key_selection.retain(|id| (1..=9).contains(id)); + self.key_selection.sort_unstable(); + self.key_selection.dedup(); self.selected_cell = self.selected_cell.clamp(1, 80); // Find is one input adapter for the same selection. Preserve an invalid // in-progress query, but never restore a second, conflicting highlight. @@ -230,6 +237,8 @@ impl PakarControllerApp { buzzer_frequency: self.buzzer_frequency, buzzer_duration: self.buzzer_duration, key_positions: self.key_positions, + key_selection: self.key_selection.cells(), + key_trigger_on_click: self.key_trigger_on_click, settings_page: self.device_settings.page, settings_search: self.device_settings.search.clone(), pixel_probe: self.pixel_diagnostic.probe(), @@ -316,6 +325,8 @@ impl PakarControllerApp { self.buzzer_frequency = snapshot.buzzer_frequency; self.buzzer_duration = snapshot.buzzer_duration; self.key_positions = snapshot.key_positions; + self.key_selection.group(snapshot.key_selection, false); + self.key_trigger_on_click = snapshot.key_trigger_on_click; self.device_settings.page = snapshot.settings_page; self.device_settings.search = snapshot.settings_search; self.pixel_diagnostic.set_probe(snapshot.pixel_probe); @@ -424,6 +435,8 @@ mod tests { let identity = Identity::observed(&status("board-a", 'a', "table")).unwrap(); let snapshot = Snapshot { selection: vec![3, 2, 1, 3], + key_selection: vec![9, 2, 2, 0, 80], + key_trigger_on_click: true, selected_cell: 2, find: "1,2,3".into(), pixel_probe: crate::pixel_diagnostic::Probe { @@ -448,6 +461,9 @@ mod tests { let restored: Store = serde_json::from_slice(&bytes).unwrap(); let value = restored.get(&identity).unwrap(); assert_eq!(value.selection, vec![1, 2, 3]); + assert_eq!(value.key_selection, vec![2, 9]); + assert!(value.key_trigger_on_click); + assert!(!serde_json::from_str::("{}").unwrap().key_trigger_on_click); assert_eq!(value.find, "1–3"); assert_eq!(value.selected_cell, 2); assert_eq!(value.display_text, "Unsaved text"); diff --git a/desktop/src/pixel_corrections.rs b/desktop/src/pixel_corrections.rs index ca6125a6..cf1ff693 100644 --- a/desktop/src/pixel_corrections.rs +++ b/desktop/src/pixel_corrections.rs @@ -1,5 +1,45 @@ use serde::{Deserialize, Serialize}; pub const PATH: &str = "/api/pixels/corrections"; +pub const PREVIEW_PATH: &str = "/api/pixels/corrections/preview"; +pub const PREVIEW_FEATURE: &str = "pixel-corrections-preview"; +pub const PREVIEW_COMMAND_FEATURE: &str = "pixel-corrections-preview-command"; +pub const PREVIEW_TTL_MS: u64 = 10_000; +pub fn rejection(reason: &str) -> String { + let detail = match reason { + "corrections_preview_owned" => { + "Another editor owns this preview. Wait for its lease to expire, then edit again" + } + "corrections_preview_expired" => { + "The preview expired. Toggle preview off/on and make a fresh edit" + } + "corrections_conflict" => "Saved corrections changed. Reload them before editing", + _ => "Preview rejected; nothing was saved or replayed", + }; + format!("{detail} ({reason})") +} +#[derive(Clone, Copy)] +pub struct DiagnosticCursor { + pub raw: u16, + pub revision: u32, +} +fn unique_wire(map: &[i16], raw: u16) -> Result { + let matches: Vec<_> = map + .iter() + .enumerate() + .filter(|(_, source)| **source == raw as i16) + .map(|(wire, _)| wire) + .collect(); + match matches.as_slice() { + [wire] => Ok(*wire as u16), + [] => { + Err("Raw diagnostic pixel is absent here; choose an output address explicitly".into()) + } + _ => Err( + "Raw diagnostic pixel reaches multiple outputs; choose an output address explicitly" + .into(), + ), + } +} #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Rule { pub kind: String, @@ -8,23 +48,47 @@ pub struct Rule { #[serde(default = "enabled_by_default")] pub enabled: bool, } -const fn enabled_by_default() -> bool { true } +const fn enabled_by_default() -> bool { + true +} impl Rule { + /// The stored address is always zero-based. Only presentation is one-based; + /// never reinterpret a previously saved rule when loading another client. + pub fn human_pixel(&self) -> u16 { + self.pixel.saturating_add(1) + } + pub fn set_human_pixel(&mut self, pixel: u16, pixels: u16) -> bool { + if !(1..=pixels).contains(&pixel) { + return false; + } + let changed = self.pixel != pixel - 1; + self.pixel = pixel - 1; + changed + } /// Integer adaptation of Pealayer's bounded numeric adjustment: never /// alter another rule, its enabled flag or the last board-owned mapping. pub fn step_pixel(&mut self, delta: i16, pixels: u16) -> bool { - if pixels == 0 { return false; } - let next=(i32::from(self.pixel)+i32::from(delta)).clamp(0,i32::from(pixels)-1) as u16; - let changed=next!=self.pixel;self.pixel=next;changed + if pixels == 0 { + return false; + } + let next = + (i32::from(self.pixel) + i32::from(delta)).clamp(0, i32::from(pixels) - 1) as u16; + let changed = next != self.pixel; + self.pixel = next; + changed } pub fn step_offset(&mut self, delta: i16, pixels: u16) -> bool { - if pixels == 0 || self.kind!="shift" { return false; } - let bound=i32::from(pixels.min(i16::MAX as u16)); - let next=(i32::from(self.offset)+i32::from(delta)).clamp(-bound,bound) as i16; - let changed=next!=self.offset;self.offset=next;changed + if pixels == 0 || self.kind != "shift" { + return false; + } + let bound = i32::from(pixels.min(i16::MAX as u16)); + let next = (i32::from(self.offset) + i32::from(delta)).clamp(-bound, bound) as i16; + let changed = next != self.offset; + self.offset = next; + changed } } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Default, Serialize, Deserialize)] pub struct Configuration { pub revision: u32, pub enabled: bool, @@ -33,6 +97,99 @@ pub struct Configuration { pub pixels: u16, #[serde(default)] pub sources: Vec, + #[serde(default)] + pub active_sources: Vec, + #[serde(default)] + pub preview: PreviewLease, +} +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct PreviewLease { + pub active: bool, + pub lease_id: u32, + pub base_revision: u32, + pub remaining_ms: u32, +} +impl Configuration { + pub fn validate(&self) -> Result<(), String> { + if self.pixels == 0 || self.pixels > 480 || self.rules.len() > 32 { + return Err("Invalid correction capacity".into()); + } + for rule in &self.rules { + if rule.pixel >= self.pixels + || !matches!(rule.kind.as_str(), "black" | "remove" | "shift") + || i32::from(rule.offset).abs() > i32::from(self.pixels) + || (rule.kind != "shift" && rule.offset != 0) + { + return Err("Invalid correction rule; saved addresses were not changed".into()); + } + } + Ok(()) + } + pub fn write_value(&self) -> serde_json::Value { + serde_json::json!({"revision":self.revision,"enabled":self.enabled,"rules":self.rules}) + } + /// Diagnostics address raw scene sources. A rule addresses an intermediate + /// outgoing slot after preceding rules, not that raw source's numeric ID. + pub fn wire_for_cursor( + &self, + cursor: DiagnosticCursor, + before_rule: usize, + ) -> Result { + if cursor.revision != self.revision + || cursor.raw >= self.pixels + || before_rule > self.rules.len() + { + return Err("Diagnostic cursor revision changed; read the board again".into()); + } + self.validate()?; + let mut prefix = self.clone(); + prefix.rules.truncate(before_rule); + let map = prefix.expected_sources()?; + unique_wire(&map, cursor.raw) + } + /// Exact integer mapping from BoardCore::PixelCorrections::configure. This + /// verifies board readback, not a replacement effects engine or physical proof. + pub fn expected_sources(&self) -> Result, String> { + self.validate()?; + let mut map: Vec = (0..self.pixels as i16).collect(); + if !self.enabled { + return Ok(map); + } + let size = i32::from(self.pixels); + for rule in self.rules.iter().filter(|rule| rule.enabled) { + let pixel = i32::from(rule.pixel); + let offset = i32::from(rule.offset); + match rule.kind.as_str() { + "black" => map[pixel as usize] = -1, + "remove" => { + for i in pixel..size - 1 { + map[i as usize] = map[i as usize + 1]; + } + map[size as usize - 1] = -1; + } + "shift" if offset > 0 => { + for i in (pixel..size).rev() { + map[i as usize] = if i - offset >= pixel { + map[(i - offset) as usize] + } else { + -1 + }; + } + } + "shift" if offset < 0 => { + let end = size + offset; + for i in (pixel + offset).max(0)..end { + map[i as usize] = map[(i - offset) as usize]; + } + for i in end.max(pixel)..size { + map[i as usize] = -1; + } + } + _ => {} + } + } + Ok(map) + } } #[derive(Default)] pub struct Editor { @@ -43,37 +200,800 @@ pub struct Editor { pub message: String, pub pending: Option, pub expected: Option, + pub live: LivePreview, + pub reload_after_preview: bool, +} + +#[derive(Clone)] +pub struct PreviewRequest { + pub id: u64, + pub value: Configuration, + pub lease: u32, + pub clear: bool, + pub at: std::time::Instant, +} +impl PreviewRequest { + pub fn from_form( + status: &crate::DeviceStatus, + form: &[(String, String)], + ) -> Result { + if form.len() != 1 || form[0].0 != "configuration" { + return Err("Invalid correction preview payload".into()); + } + let payload: serde_json::Value = + serde_json::from_str(&form[0].1).map_err(|_| "Invalid correction preview JSON")?; + let revision = payload["revision"] + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .ok_or("Invalid correction revision")?; + let lease = match payload.get("lease_id") { + None => 0, + Some(v) => v + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .ok_or("Invalid preview lease")?, + }; + let clear = match payload.get("clear") { + None => false, + Some(v) => v.as_bool().ok_or("Invalid preview clear")?, + }; + let pixels = u16::try_from(status.pixels).map_err(|_| "Invalid preview capacity")?; + let mut value = if clear { + Configuration { + revision, + pixels, + ..Default::default() + } + } else { + serde_json::from_value::(payload.clone()) + .map_err(|_| "Invalid preview configuration")? + }; + value.pixels = pixels; + value.validate()?; + if clear && lease == 0 { + return Err("Preview release needs an owned lease".into()); + } + if !clear && payload["ttl_ms"].as_u64() != Some(PREVIEW_TTL_MS) { + return Err("Invalid correction preview lease duration".into()); + } + Ok(Self { + id: 0, + value, + lease, + clear, + at: std::time::Instant::now(), + }) + } + pub fn payload(&self) -> serde_json::Value { + if self.clear { + serde_json::json!({"revision":self.value.revision,"lease_id":self.lease,"clear":true}) + } else { + let mut value = self.value.write_value(); + value["lease_id"] = self.lease.into(); + value["ttl_ms"] = PREVIEW_TTL_MS.into(); + value + } + } + pub fn verify(&self, actual: &Configuration) -> Result<(), String> { + actual.validate()?; + if actual.sources != actual.expected_sources()? + || actual.active_sources.len() != usize::from(actual.pixels) + { + return Err("Incomplete or inconsistent correction mapping readback".into()); + } + if actual.revision != self.value.revision + || actual.pixels != self.value.pixels + || actual.preview.base_revision != self.value.revision + { + return Err("Correction revision changed; preview was not confirmed".into()); + } + if self.clear { + if actual.preview.active + || actual.preview.lease_id != 0 + || actual.active_sources != actual.sources + { + return Err("Preview release was not confirmed".into()); + } + } else if !actual.preview.active + || actual.preview.lease_id == 0 + || (self.lease != 0 && actual.preview.lease_id != self.lease) + || actual.preview.remaining_ms == 0 + || u64::from(actual.preview.remaining_ms) > PREVIEW_TTL_MS + || actual.active_sources != self.value.expected_sources()? + { + return Err("Live correction readback differs; no preview was confirmed".into()); + } + Ok(()) + } +} + +fn decode_response(response: reqwest::blocking::Response) -> Result { + let status = response.status(); + let value: serde_json::Value = response.json().map_err(|_| "Invalid correction response")?; + if !status.is_success() { + return Err(format!( + "HTTP {status}: {}", + rejection( + value["reason"] + .as_str() + .or_else(|| value["error"].as_str()) + .unwrap_or("unknown") + ) + )); + } + serde_json::from_value(value).map_err(|_| "Invalid correction readback".into()) +} +fn finish_http_preview( + client: &reqwest::blocking::Client, + endpoint: &crate::Endpoint, + peer: &crate::peer_http::Pin, + request: &PreviewRequest, + actual: Configuration, + admit: impl Fn() -> Result<(), String>, +) -> Result { + admit()?; + request.verify(&actual)?; + // The correction document has no producer envelope on older firmware. + // Fenced headers plus a same-boot trailing status retain the existing guard. + let after = crate::blocking_status(client, endpoint)?; + admit()?; + if crate::peer_http::Pin::from_status(&after).as_ref() != Some(peer) { + return Err("Correction preview peer changed; nothing replayed".into()); + } + Ok(actual) +} +pub fn send_http_preview( + client: &reqwest::blocking::Client, + endpoint: &crate::Endpoint, + peer: &crate::peer_http::Pin, + request: &PreviewRequest, + admit: impl Fn() -> Result<(), String>, +) -> Result { + admit()?; + request.value.validate()?; + let actual = decode_response( + client + .post(format!( + "{}{}", + endpoint.base.trim_end_matches('/'), + PREVIEW_PATH + )) + .basic_auth(&endpoint.user, Some(&endpoint.password)) + .json(&request.payload()) + .send() + .map_err(|_| "Correction preview outcome unknown; no command replayed")?, + )?; + finish_http_preview(client, endpoint, peer, request, actual, admit) +} +pub fn read_preview( + endpoint: &crate::Endpoint, + peer: &crate::peer_http::Pin, + request: &PreviewRequest, + admit: impl Fn() -> Result<(), String>, +) -> Result { + admit()?; + let client = peer.client()?; + let actual = decode_response( + client + .get(format!("{}{}", endpoint.base.trim_end_matches('/'), PATH)) + .basic_auth(&endpoint.user, Some(&endpoint.password)) + .send() + .map_err(|_| "Correction preview readback unavailable; no command replayed")?, + )?; + finish_http_preview(&client, endpoint, peer, request, actual, admit) +} + +/// Default-on is a preference, not startup authority. Only an explicit local +/// edit engages a lease. One request is in flight; successful observations renew +/// it while the editor stays visible. Failure stops, never retries a mutation. +pub struct LivePreview { + pub enabled: bool, + pub engaged: bool, + pub pending: Option, + pub observed: Option, + pub error: String, + pub painted_at: Option, + lease: u32, + next_id: u64, + due: Option, + expires: Option, + release: bool, + blocked: bool, +} +impl Default for LivePreview { + fn default() -> Self { + Self { + enabled: true, + engaged: false, + pending: None, + observed: None, + error: String::new(), + painted_at: None, + lease: 0, + next_id: 0, + due: None, + expires: None, + release: false, + blocked: false, + } + } +} +impl LivePreview { + pub fn edited(&mut self, now: std::time::Instant) { + if self.enabled && !self.blocked { + self.engaged = true; + self.due = Some(now + std::time::Duration::from_millis(280)); + } + } + pub fn toggle(&mut self, enabled: bool) { + self.enabled = enabled; + self.blocked = false; + self.error.clear(); + if !enabled { + self.stop(); + } + // Re-enabling alone does not apply an old draft. + } + pub fn stop(&mut self) { + self.engaged = false; + self.due = None; + self.release = self.lease != 0 || self.pending.is_some(); + } + pub fn committed(&mut self) { + let enabled = self.enabled; + let next_id = self.next_id; + *self = Self::default(); + self.enabled = enabled; + self.next_id = next_id; + } + pub fn save_lease(&self) -> Option { + (self.lease != 0).then_some(self.lease) + } + pub fn next( + &mut self, + value: &Configuration, + now: std::time::Instant, + ) -> Option { + if let Some(pending) = &self.pending { + if now.saturating_duration_since(pending.at).as_millis() >= 8000 { + self.failed("Preview receipt timed out; no command was replayed".into()); + } + return None; + } + if self.expires.is_some_and(|expires| now >= expires) { + self.lease = 0; + self.observed = None; + self.expires = None; + self.failed("Live preview expired; saved corrections remain unchanged".into()); + } + let clear = self.release && self.lease != 0; + if !clear + && (!self.enabled + || !self.engaged + || self.blocked + || self.due.is_none_or(|due| now < due)) + { + return None; + } + // Cleanup is bound to the last confirmed lease, never to a malformed + // draft that the user may currently be correcting. + let value = if clear { + self.observed.as_ref().unwrap_or(value) + } else { + value + }; + if let Err(error) = value.validate() { + self.failed(error); + return None; + } + let Some(next_id) = self.next_id.checked_add(1) else { + self.failed("Preview request IDs exhausted; reopen the connection".into()); + return None; + }; + self.next_id = next_id; + let request = PreviewRequest { + id: self.next_id, + value: value.clone(), + lease: self.lease, + clear, + at: now, + }; + self.pending = Some(request.clone()); + self.release = false; + self.due = None; + Some(request) + } + pub fn finish( + &mut self, + id: u64, + result: Result, + now: std::time::Instant, + ) { + let Some(request) = self + .pending + .as_ref() + .filter(|request| request.id == id) + .cloned() + else { + return; + }; + self.pending = None; + let actual = match result.and_then(|actual| { + request.verify(&actual)?; + Ok(actual) + }) { + Ok(actual) => actual, + Err(error) => { + if request.clear { + self.lease = 0; + self.expires = None; + self.observed = None; + } + self.failed(error); + return; + } + }; + if request.clear { + self.lease = 0; + self.observed = None; + self.expires = None; + self.release = false; + return; + } + // Count transit against the lease; never assume a delayed reply bought + // another full TTL. A cancelled in-flight acquire is released once. + let remaining = std::time::Duration::from_millis(u64::from(actual.preview.remaining_ms)) + .min( + std::time::Duration::from_millis(PREVIEW_TTL_MS) + .saturating_sub(now.saturating_duration_since(request.at)), + ); + self.lease = actual.preview.lease_id; + self.expires = Some(now + remaining); + self.observed = Some(actual); + if remaining < std::time::Duration::from_millis(500) { + self.failed("Preview confirmation arrived too late; nothing was saved".into()); + return; + } + if !self.engaged || !self.enabled || self.release { + self.release = true; + return; + } + let renew = now + remaining / 2; + self.due = Some(self.due.map_or(renew, |edit| edit.min(renew))); + } + pub fn failed(&mut self, error: String) { + if self.pending.as_ref().is_some_and(|request| request.clear) { + self.lease = 0; + self.expires = None; + self.observed = None; + } + self.pending = None; + self.engaged = false; + self.due = None; + self.blocked = true; + self.error = error; + self.release = self.lease != 0; + } } #[cfg(test)] mod tests { use super::*; + fn config() -> Configuration { + Configuration { + revision: 7, + enabled: true, + pixels: 6, + sources: (0..6).collect(), + rules: vec![Rule { + kind: "black".into(), + pixel: 0, + offset: 0, + enabled: true, + }], + ..Default::default() + } + } + fn reply(request: &PreviewRequest, lease: u32) -> Configuration { + let mut value = config(); + value.rules.clear(); // Durable configuration remains the identity map. + value.active_sources = if request.clear { + value.sources.clone() + } else { + request.value.expected_sources().unwrap() + }; + value.preview = PreviewLease { + active: !request.clear, + lease_id: if request.clear { 0 } else { lease }, + base_revision: request.value.revision, + remaining_ms: if request.clear { 0 } else { 9500 }, + }; + value + } + fn status() -> crate::DeviceStatus { + serde_json::from_value( + serde_json::json!({"board_id":"esp32s3-fixture","boot_id":"a".repeat(32), + "http_peer_fencing":true,"version":crate::peer_identity::VERSION, + "compatibility":crate::peer_identity::advertised_policy(),"pixels":6, + "capabilities":{"board_kind":"table","acknowledged_features":["pixel-corrections-preview"]}}), + ) + .unwrap() + } + #[test] + fn pixel_corrections_cursor_uses_prefix_mapping_not_raw_address() { + let mut value = config(); + value.rules[0].kind = "remove".into(); + value.rules[0].pixel = 1; + let cursor = DiagnosticCursor { + raw: 4, + revision: 7, + }; + assert_eq!(value.wire_for_cursor(cursor, 0).unwrap(), 4); + assert_eq!(value.wire_for_cursor(cursor, 1).unwrap(), 3); + assert!( + value + .wire_for_cursor(DiagnosticCursor { raw: 1, ..cursor }, 1) + .is_err() + ); + assert!( + value + .wire_for_cursor( + DiagnosticCursor { + revision: 8, + ..cursor + }, + 1 + ) + .is_err() + ); + assert!( + unique_wire(&[0, 4, 4, -1], cursor.raw) + .unwrap_err() + .contains("multiple") + ); + assert_eq!(value.rules[0].pixel, 1); + } + #[test] + fn pixel_corrections_http_typed_dispatch_is_fenced_and_never_saves() { + let status = status(); + let request = PreviewRequest { + id: 31, + value: config(), + lease: 0, + clear: false, + at: std::time::Instant::now(), + }; + let actual = reply(&request, 42); + let (endpoint, server) = crate::tests::mock_device(vec![ + (200, serde_json::to_vec(&status).unwrap()), + (200, serde_json::to_vec(&actual).unwrap()), + (200, serde_json::to_vec(&status).unwrap()), + ]); + let (tx, _) = std::sync::mpsc::channel(); + let mut queue = crate::peer_http::Queue::new(tx); + queue.observe(&status); + let peer = crate::peer_http::Pin::from_status(&status).unwrap(); + let result = crate::scoped_http_request( + &peer.client().unwrap(), + &queue.capture(), + &crate::HttpCommand::CorrectionPreview(endpoint, request), + ); + assert!( + matches!(result,Ok(crate::Event::CorrectionPreviewFinished(31,Ok(value))) if value.preview.lease_id==42) + ); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests[1].starts_with("POST /api/pixels/corrections/preview ")); + assert!(requests[1].contains("x-pakar-board: esp32s3-fixture")); + assert!(requests[1].contains(&format!("x-pakar-boot: {}", "a".repeat(32)))); + let body: serde_json::Value = + serde_json::from_str(requests[1].split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["rules"][0]["pixel"], 0); + assert_eq!(body["ttl_ms"], PREVIEW_TTL_MS); + assert!(body.get("save").is_none()); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + } + #[test] + fn pixel_corrections_http_rejection_preserves_reason_without_replay_or_save() { + let status = status(); + let request = PreviewRequest { + id: 31, + value: config(), + lease: 0, + clear: false, + at: std::time::Instant::now(), + }; + let (endpoint, server) = crate::tests::mock_device(vec![ + (200, serde_json::to_vec(&status).unwrap()), + (409, br#"{"reason":"corrections_preview_owned"}"#.to_vec()), + ]); + let (tx, _) = std::sync::mpsc::channel(); + let mut queue = crate::peer_http::Queue::new(tx); + queue.observe(&status); + let peer = crate::peer_http::Pin::from_status(&status).unwrap(); + let result = crate::scoped_http_request( + &peer.client().unwrap(), + &queue.capture(), + &crate::HttpCommand::CorrectionPreview(endpoint, request), + ); + assert!(result.err().unwrap().contains("corrections_preview_owned")); + assert_eq!(server.join().unwrap().len(), 2); + } + #[test] + fn pixel_corrections_http_release_requires_owned_lease_and_same_boot_readback() { + let status = status(); + let peer = crate::peer_http::Pin::from_status(&status).unwrap(); + let request = PreviewRequest { + id: 32, + value: config(), + lease: 42, + clear: true, + at: std::time::Instant::now(), + }; + let mut next = status.clone(); + next.boot_id = "b".repeat(32); + let (endpoint, server) = crate::tests::mock_device(vec![ + (200, serde_json::to_vec(&reply(&request, 42)).unwrap()), + (200, serde_json::to_vec(&next).unwrap()), + ]); + let result = send_http_preview(&peer.client().unwrap(), &endpoint, &peer, &request, || { + Ok(()) + }); + assert!(result.err().unwrap().contains("peer changed")); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 2); + let body: serde_json::Value = + serde_json::from_str(requests[0].split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!( + body, + serde_json::json!({"revision":7,"lease_id":42,"clear":true}) + ); + } + #[test] + fn pixel_corrections_clear_cannot_confirm_missing_or_inconsistent_saved_map() { + let request = PreviewRequest { + id: 1, + value: config(), + lease: 42, + clear: true, + at: std::time::Instant::now(), + }; + let mut actual = reply(&request, 42); + actual.active_sources.clear(); + actual.sources.clear(); + assert!(request.verify(&actual).is_err()); + let mut actual = reply(&request, 42); + actual.sources[0] = -1; + actual.active_sources[0] = -1; + assert!(request.verify(&actual).is_err()); + } + #[test] + fn pixel_corrections_human_addresses_round_trip_without_saved_rule_migration() { + for raw in [0, 1, 5, 6, 478, 479] { + let mut rule: Rule = serde_json::from_value( + serde_json::json!({"kind":"remove","pixel":raw,"offset":0,"enabled":false}), + ) + .unwrap(); + assert_eq!(rule.human_pixel(), raw + 1); + assert!(!rule.set_human_pixel(raw + 1, 480)); + assert_eq!(serde_json::to_value(&rule).unwrap()["pixel"], raw); + assert!(!rule.enabled); + } + let mut rule = config().rules.remove(0); + assert!(!rule.set_human_pixel(0, 480)); + assert!(!rule.set_human_pixel(481, 480)); + assert!(rule.set_human_pixel(480, 480)); + assert_eq!(rule.pixel, 479); + } + #[test] + fn pixel_corrections_preview_preference_defaults_on_and_round_trips_without_a_lease() { + let mut config: crate::config::AppConfig = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(config.pixel_correction_live_preview); + config.pixel_correction_live_preview = false; + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["pixel_correction_live_preview"], false); + assert!(json.get("lease_id").is_none()); + assert!( + !serde_json::from_value::(json) + .unwrap() + .pixel_correction_live_preview + ); + } + #[test] + fn pixel_corrections_mapping_matches_shared_ordered_wire_semantics() { + let mut value = config(); + assert_eq!(value.expected_sources().unwrap(), [-1, 1, 2, 3, 4, 5]); + value.rules[0].kind = "remove".into(); + value.rules[0].pixel = 1; + assert_eq!(value.expected_sources().unwrap(), [0, 2, 3, 4, 5, -1]); + value.rules.push(Rule { + kind: "shift".into(), + pixel: 2, + offset: 1, + enabled: true, + }); + assert_eq!(value.expected_sources().unwrap(), [0, 2, -1, 3, 4, 5]); + value.rules[0].enabled = false; + value.rules[1].offset = -2; + assert_eq!(value.expected_sources().unwrap(), [2, 3, 4, 5, -1, -1]); + value.enabled = false; + assert_eq!(value.expected_sources().unwrap(), [0, 1, 2, 3, 4, 5]); + } + #[test] + fn pixel_corrections_preview_defaults_on_but_only_edit_acquires_and_never_saves() { + let now = std::time::Instant::now(); + let mut live = LivePreview::default(); + let value = config(); + assert!(live.enabled); + assert!(live.next(&value, now).is_none()); + live.edited(now); + assert!(live.next(&value, now).is_none()); + let sent = now + std::time::Duration::from_millis(300); + let request = live.next(&value, sent).unwrap(); + assert_eq!(request.payload()["lease_id"], 0); + assert_eq!(request.payload()["revision"], 7); + assert_eq!(request.payload()["rules"][0]["pixel"], 0); + assert!(request.payload().get("save").is_none()); + assert!(live.next(&value, sent).is_none()); + live.finish(request.id, Ok(reply(&request, 42)), sent); + assert_eq!(live.save_lease(), Some(42)); + assert!(live.engaged); + assert!( + live.next(&value, sent + std::time::Duration::from_secs(1)) + .is_none() + ); + let renewed = live + .next(&value, sent + std::time::Duration::from_secs(5)) + .unwrap(); + assert_eq!(renewed.lease, 42); + assert!(!renewed.clear); + assert_eq!(value.revision, 7); + } + #[test] + fn pixel_corrections_cancelled_acquire_releases_only_returned_owner_once() { + let now = std::time::Instant::now(); + let mut live = LivePreview::default(); + let value = config(); + live.edited(now); + let sent = now + std::time::Duration::from_millis(300); + let request = live.next(&value, sent).unwrap(); + live.stop(); + live.finish(request.id, Ok(reply(&request, 91)), sent); + let clear = live.next(&value, sent).unwrap(); + assert!(clear.clear); + assert_eq!(clear.lease, 91); + live.finish(clear.id, Err("Disconnected".into()), sent); + assert!( + live.next(&value, sent + std::time::Duration::from_secs(1)) + .is_none(), + "Failed clear never retries" + ); + assert!(!live.engaged); + } + #[test] + fn pixel_corrections_invalid_draft_can_still_release_confirmed_lease() { + let now = std::time::Instant::now(); + let mut live = LivePreview::default(); + let mut value = config(); + live.edited(now); + let sent = now + std::time::Duration::from_millis(300); + let request = live.next(&value, sent).unwrap(); + live.finish(request.id, Ok(reply(&request, 42)), sent); + value.rules[0].pixel = 6; + live.stop(); + let clear = live.next(&value, sent).unwrap(); + assert!(clear.clear); + assert_eq!(clear.lease, 42); + assert!(clear.value.validate().is_ok()); + } + #[test] + fn pixel_corrections_foreign_or_inexact_receipts_cannot_confirm_preview() { + let now = std::time::Instant::now(); + let value = config(); + let request = PreviewRequest { + id: 1, + value: value.clone(), + lease: 42, + clear: false, + at: now, + }; + for bad in 0..5 { + let mut actual = reply(&request, 42); + match bad { + 0 => actual.preview.lease_id = 43, + 1 => actual.revision += 1, + 2 => actual.active_sources[0] = 0, + 3 => actual.preview.remaining_ms = 0, + _ => actual.preview.base_revision += 1, + } + assert!(request.verify(&actual).is_err()); + } + let mut live = LivePreview::default(); + live.edited(now); + let request = live + .next(&value, now + std::time::Duration::from_millis(300)) + .unwrap(); + live.finish(request.id + 1, Ok(reply(&request, 42)), now); + assert!(live.pending.is_some()); + live.finish(request.id, Err("Uncertain transport".into()), now); + live.edited(now); + assert!( + live.next(&value, now + std::time::Duration::from_secs(1)) + .is_none() + ); + } + #[test] + fn pixel_corrections_preview_expiry_never_reacquires_automatically() { + let now = std::time::Instant::now(); + let mut live = LivePreview::default(); + let value = config(); + live.edited(now); + let sent = now + std::time::Duration::from_millis(300); + let request = live.next(&value, sent).unwrap(); + live.finish(request.id, Ok(reply(&request, 42)), sent); + assert!( + live.next(&value, sent + std::time::Duration::from_secs(11)) + .is_none() + ); + assert_eq!(live.save_lease(), None); + assert!(!live.engaged); + } #[test] fn pixel_corrections_disabled_rules_survive_read_edit_reorder_and_write() { - let mut value:Configuration=serde_json::from_value(serde_json::json!({"revision":7,"enabled":true,"pixels":480, + let mut value: Configuration = + serde_json::from_value(serde_json::json!({"revision":7,"enabled":true,"pixels":480, "sources":[0,-1,2],"rules":[{"kind":"shift","pixel":1,"offset":-1,"enabled":false}, - {"kind":"black","pixel":9,"offset":0}]})).unwrap(); - assert!(!value.rules[0].enabled);assert!(value.rules[1].enabled,"Omitted old field inherits true only"); - assert!(value.rules[0].step_pixel(1,value.pixels)); - assert!(value.rules[0].step_offset(1,value.pixels)); - value.rules.swap(0,1); - let saved=serde_json::to_value(&value).unwrap(); - assert_eq!(saved["rules"][1]["enabled"],false); - assert_eq!(saved["rules"][1]["pixel"],2); - assert_eq!(saved["sources"],serde_json::json!([0,-1,2])); - assert_eq!(saved["revision"],7); - assert!(serde_json::from_value::(serde_json::json!({"kind":"black","pixel":0,"offset":0,"enabled":"false"})).is_err()); + {"kind":"black","pixel":9,"offset":0}]})) + .unwrap(); + assert!(!value.rules[0].enabled); + assert!( + value.rules[1].enabled, + "Omitted old field inherits true only" + ); + assert!(value.rules[0].step_pixel(1, value.pixels)); + assert!(value.rules[0].step_offset(1, value.pixels)); + value.rules.swap(0, 1); + let saved = serde_json::to_value(&value).unwrap(); + assert_eq!(saved["rules"][1]["enabled"], false); + assert_eq!(saved["rules"][1]["pixel"], 2); + assert_eq!(saved["sources"], serde_json::json!([0, -1, 2])); + assert_eq!(saved["revision"], 7); + assert!( + serde_json::from_value::( + serde_json::json!({"kind":"black","pixel":0,"offset":0,"enabled":"false"}) + ) + .is_err() + ); } #[test] fn pixel_corrections_integer_adjustments_obey_boundaries_without_reenabling() { - let mut rule=Rule{kind:"shift".into(),pixel:0,offset:-480,enabled:false}; - assert!(!rule.step_pixel(-1,480));assert!(!rule.step_offset(-1,480)); - assert!(rule.step_pixel(1,480));assert!(rule.step_offset(1,480)); - rule.pixel=479;rule.offset=480; - assert!(!rule.step_pixel(1,480));assert!(!rule.step_offset(1,480)); - assert!(rule.step_pixel(-1,480));assert!(rule.step_offset(-1,480)); + let mut rule = Rule { + kind: "shift".into(), + pixel: 0, + offset: -480, + enabled: false, + }; + assert!(!rule.step_pixel(-1, 480)); + assert!(!rule.step_offset(-1, 480)); + assert!(rule.step_pixel(1, 480)); + assert!(rule.step_offset(1, 480)); + rule.pixel = 479; + rule.offset = 480; + assert!(!rule.step_pixel(1, 480)); + assert!(!rule.step_offset(1, 480)); + assert!(rule.step_pixel(-1, 480)); + assert!(rule.step_offset(-1, 480)); assert!(!rule.enabled); - assert!(!rule.step_pixel(1,0));assert!(!rule.step_offset(1,0)); - rule.kind="black".into();assert!(!rule.step_offset(-1,480)); + assert!(!rule.step_pixel(1, 0)); + assert!(!rule.step_offset(1, 0)); + rule.kind = "black".into(); + assert!(!rule.step_offset(-1, 480)); } } diff --git a/desktop/src/pixel_groups.rs b/desktop/src/pixel_groups.rs index c1e816d2..5848fc29 100644 --- a/desktop/src/pixel_groups.rs +++ b/desktop/src/pixel_groups.rs @@ -137,6 +137,7 @@ pub struct Editor { pub open: bool, } pub enum Purpose { + Minigame(crate::minigame::UsbRequest), Groups, Frame { revision: u32, layout_revision: u32, at: Instant }, Diagnostic { id: u64, probe: crate::pixel_diagnostic::Probe, off: bool, request_id: String, previous: DeviceStatus }, diff --git a/desktop/src/platform/host_wifi.rs b/desktop/src/platform/host_wifi.rs new file mode 100644 index 00000000..f7374661 --- /dev/null +++ b/desktop/src/platform/host_wifi.rs @@ -0,0 +1,148 @@ +//! Host Wi-Fi only. OS profiles own credentials; no network mutation on discovery. +#[derive(Clone, Debug, PartialEq)] +pub struct Profile { + pub name: String, + pub adapter: String, + #[cfg(windows)] + interface: windows::core::GUID, +} + +#[cfg(windows)] +mod native { + use super::Profile; + use windows::{ + Win32::{Foundation::HANDLE, NetworkManagement::WiFi::*}, + core::PCWSTR, + }; + struct Client(HANDLE); + impl Drop for Client { + fn drop(&mut self) { + unsafe { + WlanCloseHandle(self.0, None); + } + } + } + struct Allocation(*mut T); + impl Drop for Allocation { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + WlanFreeMemory(self.0.cast()); + } + } + } + } + fn check(code: u32) -> Result<(), String> { + if code == 0 { + Ok(()) + } else { + Err(format!( + "Windows Wi-Fi error {code}. Check the adapter, WLAN service and Wi-Fi settings." + )) + } + } + fn client() -> Result { + let mut handle = HANDLE::default(); + let mut version = 0; + check(unsafe { WlanOpenHandle(2, None, &mut version, &mut handle) })?; + Ok(Client(handle)) + } + fn text(value: &[u16]) -> String { + String::from_utf16_lossy( + &value[..value.iter().position(|c| *c == 0).unwrap_or(value.len())], + ) + } + pub fn profiles() -> Result, String> { + let client = client()?; + let mut interfaces = Allocation(std::ptr::null_mut::()); + check(unsafe { WlanEnumInterfaces(client.0, None, &mut interfaces.0) })?; + if interfaces.0.is_null() { + return Err("Windows returned no Wi-Fi adapters".into()); + } + let mut result = Vec::new(); + unsafe { + let count = (*interfaces.0).dwNumberOfItems as usize; + if count > 64 { + return Err("Unexpected Wi-Fi adapter count".into()); + } + let items = std::slice::from_raw_parts( + std::ptr::addr_of!((*interfaces.0).InterfaceInfo).cast::(), + count, + ); + for interface in items { + let mut profiles = Allocation(std::ptr::null_mut::()); + check(WlanGetProfileList( + client.0, + &interface.InterfaceGuid, + None, + &mut profiles.0, + ))?; + if profiles.0.is_null() { + continue; + } + let count = (*profiles.0).dwNumberOfItems as usize; + if count > 4096 { + return Err("Unexpected saved Wi-Fi profile count".into()); + } + for profile in std::slice::from_raw_parts( + std::ptr::addr_of!((*profiles.0).ProfileInfo).cast::(), + count, + ) { + result.push(Profile { + name: text(&profile.strProfileName), + adapter: text(&interface.strInterfaceDescription), + interface: interface.InterfaceGuid, + }); + } + } + } + result.sort_by(|a, b| a.name.cmp(&b.name).then(a.adapter.cmp(&b.adapter))); + Ok(result) + } + pub fn join(profile: &Profile) -> Result { + // Recheck the exact adapter/profile; never select a different interface. + if !profiles()?.contains(profile) { + return Err("Saved profile changed; refresh the profile list.".into()); + } + let client = client()?; + let name: Vec = profile.name.encode_utf16().chain(Some(0)).collect(); + let parameters = WLAN_CONNECTION_PARAMETERS { + wlanConnectionMode: wlan_connection_mode_profile, + strProfile: PCWSTR(name.as_ptr()), + dot11BssType: dot11_BSS_type_infrastructure, + ..Default::default() + }; + check(unsafe { WlanConnect(client.0, &profile.interface, ¶meters, None) })?; + Ok(format!( + "Windows accepted the request for {}. Connect to the controller to verify reachability.", + profile.name + )) + } + pub fn settings() -> Result { + use std::os::windows::process::CommandExt; + // Fixed OS URI, no shell interpolation or secret arguments. + std::process::Command::new("explorer.exe") + .arg("ms-settings:network-wifi") + .creation_flags(0x08000000) + .spawn() + .map_err(|e| format!("Could not open Wi-Fi settings: {e}"))?; + Ok( + "Choose or add the network in Windows, then return and connect to the controller." + .into(), + ) + } +} +#[cfg(windows)] +pub use native::{join, profiles, settings}; +#[cfg(not(windows))] +pub fn profiles() -> Result, String> { + Err("Use your system Wi-Fi settings to join the board’s network.".into()) +} +#[cfg(not(windows))] +pub fn join(_: &Profile) -> Result { + Err("Use your system Wi-Fi settings.".into()) +} +#[cfg(not(windows))] +pub fn settings() -> Result { + Err("Open your operating system’s Wi-Fi settings, then reconnect.".into()) +} diff --git a/desktop/src/platform/instance.rs b/desktop/src/platform/instance.rs index ea17d656..6dcca789 100644 --- a/desktop/src/platform/instance.rs +++ b/desktop/src/platform/instance.rs @@ -30,6 +30,8 @@ pub enum Command { Refresh, Fullscreen, About, + /// Opens the separately installed mini-app; never starts a round or connects a board. + Snake, Workspaces, Panel { name: String, @@ -265,6 +267,15 @@ pub fn acquire_gui_ownership() -> Result { pub fn acquire_gui_ownership() -> Result { Ok(GuiOwnership::Primary(GuiOwnershipGuard)) } +// Live snapshots include board capabilities, artwork and pixel state. Keep +// requests small, but do not truncate replies at the pipe's 64 KiB buffer hint. +const MAX_REPLY_BYTES: usize = 1024 * 1024; +fn encode_reply(response: &serde_json::Value) -> Vec { + match serde_json::to_vec(response) { + Ok(bytes) if bytes.len() <= MAX_REPLY_BYTES => bytes, + _ => br#"{"accepted":false,"error":"IPC snapshot exceeds reply budget"}"#.to_vec(), + } +} #[cfg(windows)] pub fn send(command: &Command, timeout: Duration) -> Result { use windows::{ @@ -273,7 +284,7 @@ pub fn send(command: &Command, timeout: Duration) -> Result = pipe_name().encode_utf16().chain(Some(0)).collect(); let request = serde_json::to_vec(command).map_err(|e| e.to_string())?; - let mut buffer = vec![0u8; 65536]; + let mut buffer = vec![0u8; MAX_REPLY_BYTES]; let mut read = 0; let ok = unsafe { CallNamedPipeW( @@ -356,7 +367,7 @@ fn listener( } Err(error)=>serde_json::json!({"accepted":false,"error":error.to_string()}), }; - let reply=serde_json::to_vec(&response).unwrap_or_default();let mut written=0; + let reply=encode_reply(&response);let mut written=0; unsafe {let _=WriteFile(handle,Some(&reply),Some(&mut written),None);let _=FlushFileBuffers(handle);} } unsafe {let _=DisconnectNamedPipe(handle);} @@ -369,6 +380,17 @@ fn listener( mod tests { use super::*; #[test] + fn live_snapshot_reply_is_complete_and_bounded() { + let snapshot = serde_json::json!({"ready":true,"pixels":"x".repeat(180_000)}); + let encoded = encode_reply(&snapshot); + assert!(encoded.len() > 65536); + assert_eq!(serde_json::from_slice::(&encoded).unwrap(), snapshot); + let oversized = serde_json::json!({"pixels":"x".repeat(MAX_REPLY_BYTES)}); + let rejected: serde_json::Value = serde_json::from_slice(&encode_reply(&oversized)).unwrap(); + assert_eq!(rejected["accepted"], false); + assert!(rejected["error"].is_string()); + } + #[test] fn connection_and_led_ipc_are_explicit_typed_and_bounded() { assert!(matches!( parse_command(br#"{"command":"status"}"#), @@ -418,6 +440,9 @@ mod tests { } #[test] fn launch_requests_use_only_known_typed_commands() { + assert!(matches!(parse_command(br#"{"command":"snake"}"#), Ok(Command::Snake))); + assert!(parse_command(br#"{"command":"snake","start":true}"#).is_err()); + assert!(matches!(crate::ui::shell::launch_command(&["--snake".into()]), Command::Snake)); assert!(parse_command(br#"{"command":"cell_backgrounds","value":"on"}"#).is_ok()); assert!(parse_command(br#"{"command":"cell_backgrounds","value":"off"}"#).is_ok()); for bytes in [br#"{"command":"cell_backgrounds","value":"toggle"}"#.as_slice(), diff --git a/desktop/src/platform/mod.rs b/desktop/src/platform/mod.rs index 1392fc6a..2295c1eb 100644 --- a/desktop/src/platform/mod.rs +++ b/desktop/src/platform/mod.rs @@ -1,9 +1,12 @@ pub mod discovery; pub mod instance; +pub mod host_wifi; pub mod notifications; #[cfg(windows)] pub mod windows_desktop; #[cfg(windows)] +pub mod windows_window_icons; +#[cfg(windows)] pub mod windows_jump_list; #[cfg(windows)] pub mod windows_notifications; diff --git a/desktop/src/platform/notifications.rs b/desktop/src/platform/notifications.rs index d841b4b7..f532e64d 100644 --- a/desktop/src/platform/notifications.rs +++ b/desktop/src/platform/notifications.rs @@ -139,11 +139,21 @@ pub fn private_text(request: &ToastRequest) -> (&'static str, &'static str) { ), Some("pakar.connection") => ( "PakarController connection changed", - "The selected device connection changed. Open PakarController for its current status.", + // Only our typed connection observations may cross into an OS + // notification. Never substitute an untrusted peer/API message. + match request.message.as_str() { + "Fresh status from the selected device was lost." => + "Device disconnected. Fresh status is unavailable.", + "The ESP is reachable, but it reports the AVR offline." => + "ESP connected. AVR offline.", + "Fresh device status is available and the ESP reports the AVR online." => + "ESP connected. AVR online.", + _ => "Device connection status changed.", + }, ), Some("pakar.maintenance") if request.severity == Severity::Success => ( "PakarController maintenance completed", - "The requested maintenance operation completed. Open PakarController to inspect the result.", + "The requested maintenance operation completed.", ), Some("pakar.settings") if request.severity == Severity::Success => ( "PakarController settings saved", @@ -151,15 +161,15 @@ pub fn private_text(request: &ToastRequest) -> (&'static str, &'static str) { ), _ if request.severity == Severity::Error => ( "PakarController operation failed", - "An operation did not complete. Open PakarController to inspect the error before retrying.", + "The requested operation failed.", ), _ if request.severity == Severity::Warning => ( "PakarController needs attention", - "Open PakarController to inspect the warning.", + "The requested operation needs attention.", ), _ => ( "PakarController", - "There is a new application message. Open PakarController for details.", + "Application status changed.", ), } } @@ -328,6 +338,20 @@ mod tests { } } #[test] + fn connection_toast_contains_the_observed_status_without_peer_text() { + for (message, expected) in [ + ("Fresh status from the selected device was lost.", "Device disconnected. Fresh status is unavailable."), + ("The ESP is reachable, but it reports the AVR offline.", "ESP connected. AVR offline."), + ("Fresh device status is available and the ESP reports the AVR online.", "ESP connected. AVR online."), + ] { + let mut notice = request("pakar.connection", Severity::Warning); + notice.message = message.into(); + assert_eq!(private_text(¬ice).1, expected); + } + let unknown = request("pakar.connection", Severity::Warning); + assert_eq!(private_text(&unknown).1, "Device connection status changed."); + } + #[test] fn success_does_not_suppress_the_following_failure() { let mut d = Delivery::default(); d.enqueue(request("pakar.maintenance", Severity::Success)); diff --git a/desktop/src/platform/windows_desktop.rs b/desktop/src/platform/windows_desktop.rs index 1c120374..226c8b4c 100644 --- a/desktop/src/platform/windows_desktop.rs +++ b/desktop/src/platform/windows_desktop.rs @@ -23,6 +23,10 @@ pub struct Desktop { busy: Option, } impl Desktop { + pub fn set_icon(&self, icon: &eframe::egui::IconData) -> Result<(), String> { + super::windows_window_icons::apply(self.hwnd.0 as isize, icon) + } + pub fn new(context: &eframe::CreationContext<'_>) -> Result { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; let handle = context.window_handle().map_err(|e| e.to_string())?; diff --git a/desktop/src/platform/windows_window_icons.rs b/desktop/src/platform/windows_window_icons.rs new file mode 100644 index 00000000..1fd2489f --- /dev/null +++ b/desktop/src/platform/windows_window_icons.rs @@ -0,0 +1,378 @@ +//! Owned, DPI-sized 32-bit-alpha icons, independent of winit's legacy XOR mask. +use std::{ + collections::HashMap, + sync::{Arc, LazyLock, Mutex}, +}; +use windows::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + Graphics::Gdi::{CreateBitmap, DeleteObject}, + UI::WindowsAndMessaging::{ + CreateIconIndirect, DestroyIcon, HICON, ICON_BIG, ICON_SMALL, ICONINFO, SendMessageW, + WM_NCDESTROY, WM_SETICON, + }, +}; + +const SUBCLASS_ID: usize = 0x5049_434f; +type Callback = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM, usize, usize) -> LRESULT; +#[link(name = "comctl32")] +unsafe extern "system" { + fn SetWindowSubclass( + hwnd: HWND, + callback: Option, + id: usize, + data: usize, + ) -> windows::core::BOOL; + fn DefSubclassProc(hwnd: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT; +} +#[link(name = "user32")] +unsafe extern "system" { + fn GetDpiForWindow(hwnd: isize) -> u32; + fn GetSystemMetricsForDpi(index: i32, dpi: u32) -> i32; +} + +#[derive(Clone)] +struct Icons { + image: Arc, + dpi: u32, + small: isize, + big: isize, +} +static ICONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +pub fn tray_icon(hwnd: isize) -> Option { + ICONS.lock().ok()?.get(&hwnd).map(|icons| icons.small) +} + +fn create(image: &image::RgbaImage, side: u32) -> Result { + let image = image::imageops::resize(image, side, side, image::imageops::FilterType::Lanczos3); + let color = bitmap(&image).map_err(|error| error.to_string())?; + let mask_bytes = vec![0u8; (((side + 15) / 16 * 2) * side) as usize]; + unsafe { + let mask = CreateBitmap( + side as i32, + side as i32, + 1, + 1, + Some(mask_bytes.as_ptr().cast()), + ); + if mask.0.is_null() { + let _ = DeleteObject(color.into()); + return Err(windows::core::Error::from_thread().to_string()); + } + let result = CreateIconIndirect(&ICONINFO { + fIcon: true.into(), + hbmColor: color, + hbmMask: mask, + ..Default::default() + }); + let _ = DeleteObject(color.into()); + let _ = DeleteObject(mask.into()); + result + .map(|icon| icon.0 as isize) + .map_err(|error| error.to_string()) + } +} + +fn destroy(icons: Icons) { + unsafe { + let _ = DestroyIcon(HICON(icons.small as *mut _)); + let _ = DestroyIcon(HICON(icons.big as *mut _)); + } +} + +fn apply_image(hwnd: isize, image: Arc, dpi: u32) -> Result<(), String> { + let small_side = unsafe { GetSystemMetricsForDpi(49, dpi) }.clamp(16, 128) as u32; + let big_side = unsafe { GetSystemMetricsForDpi(11, dpi) }.clamp(32, 256) as u32; + let small = create(&image, small_side)?; + let big = match create(&image, big_side) { + Ok(icon) => icon, + Err(error) => { + unsafe { + let _ = DestroyIcon(HICON(small as *mut _)); + } + return Err(error); + } + }; + let next = Icons { + image, + dpi, + small, + big, + }; + if !unsafe { SetWindowSubclass(HWND(hwnd as *mut _), Some(icon_window_proc), SUBCLASS_ID, 0) } + .as_bool() + { + destroy(next); + return Err("Install native icon lifecycle hook failed".into()); + } + let previous = match ICONS.lock() { + Ok(mut cache) => cache.insert(hwnd, next), + Err(_) => { + destroy(next); + return Err("Native icon cache is unavailable".into()); + } + }; + // Release the cache lock before synchronous window messages reenter hooks. + // Only our previous handles are destroyed; returned winit/resource handles + // are borrowed and remain their owner's responsibility. + unsafe { + SendMessageW( + HWND(hwnd as *mut _), + WM_SETICON, + Some(WPARAM(ICON_SMALL as usize)), + Some(LPARAM(small)), + ); + SendMessageW( + HWND(hwnd as *mut _), + WM_SETICON, + Some(WPARAM(ICON_BIG as usize)), + Some(LPARAM(big)), + ); + } + if let Some(previous) = previous { + destroy(previous); + } + Ok(()) +} + +pub fn apply(hwnd: isize, data: &eframe::egui::IconData) -> Result<(), String> { + if hwnd == 0 + || !unsafe { windows::Win32::UI::WindowsAndMessaging::IsWindow(Some(HWND(hwnd as *mut _))) } + .as_bool() + { + return Err("Native icon target is unavailable".into()); + } + let image = image::RgbaImage::from_raw(data.width, data.height, data.rgba.clone()) + .filter(|image| image.width() != 0 && image.height() != 0) + .ok_or("Invalid RGBA icon")?; + apply_image( + hwnd, + Arc::new(image), + unsafe { GetDpiForWindow(hwnd) }.max(96), + ) +} + +unsafe extern "system" fn icon_window_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + _data: usize, +) -> LRESULT { + let raw = hwnd.0 as isize; + if message == WM_SETICON + && matches!(wparam.0, value if value == ICON_SMALL as usize || value == ICON_BIG as usize) + { + let icon = ICONS.lock().ok().and_then(|cache| { + cache.get(&raw).map(|icons| { + if wparam.0 == ICON_BIG as usize { + icons.big + } else { + icons.small + } + }) + }); + // eframe may reapply its legacy icon during DPI/viewport processing. + // Preserve the owned alpha icon without taking ownership of that handle. + return unsafe { + DefSubclassProc(hwnd, message, wparam, icon.map(LPARAM).unwrap_or(lparam)) + }; + } + if message == 0x02e0 { + // WM_DPICHANGED + let cached = ICONS.lock().ok().and_then(|cache| cache.get(&raw).cloned()); + let dpi = (wparam.0 & 0xffff) as u32; + if let Some(cached) = cached + && dpi != 0 + && dpi != cached.dpi + { + if let Err(error) = apply_image(raw, cached.image, dpi) { + eprintln!("Refresh native window icon: {error}"); + } + } + } + let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + if message == WM_NCDESTROY { + let previous = ICONS.lock().ok().and_then(|mut cache| cache.remove(&raw)); + if let Some(previous) = previous { + destroy(previous); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hidden_window_keeps_both_icon_slots_and_releases_only_owned_handles() { + use windows::{ + Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, GetIconInfo, WINDOW_EX_STYLE, WINDOW_STYLE, + WM_GETICON, + }, + core::w, + }; + struct Window(HWND); + impl Drop for Window { + fn drop(&mut self) { + unsafe { + let _ = DestroyWindow(self.0); + } + } + } + struct Icon(isize); + impl Drop for Icon { + fn drop(&mut self) { + unsafe { + let _ = DestroyIcon(HICON(self.0 as *mut _)); + } + } + } + fn exists(icon: isize) -> bool { + let mut info = ICONINFO::default(); + unsafe { + if GetIconInfo(HICON(icon as *mut _), &mut info).is_err() { + return false; + } + let _ = DeleteObject(info.hbmColor.into()); + let _ = DeleteObject(info.hbmMask.into()); + } + true + } + // A private, never-visible fixture: no live window, focus, tray or hardware. + let window = Window(unsafe { + CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("STATIC"), + w!("Icon fixture"), + WINDOW_STYLE::default(), + 0, + 0, + 32, + 32, + None, + None, + None, + None, + ) + .unwrap() + }); + let raw = window.0.0 as isize; + let image = Arc::new(image::RgbaImage::from_pixel( + 64, + 64, + image::Rgba([20, 130, 240, 200]), + )); + apply_image(raw, image.clone(), 96).unwrap(); + let first = ICONS.lock().unwrap().get(&raw).unwrap().clone(); + let get = |slot| unsafe { + SendMessageW( + window.0, + WM_GETICON, + Some(WPARAM(slot as usize)), + Some(LPARAM(0)), + ) + .0 + }; + assert_eq!(get(ICON_SMALL), first.small); + assert_eq!(get(ICON_BIG), first.big); + assert_eq!(tray_icon(raw), Some(first.small)); + let borrowed = Icon(create(&image, 16).unwrap()); + unsafe { + SendMessageW( + window.0, + WM_SETICON, + Some(WPARAM(ICON_SMALL as usize)), + Some(LPARAM(borrowed.0)), + ); + } + assert_eq!(get(ICON_SMALL), first.small); + assert!( + exists(borrowed.0), + "borrowed legacy icon must remain owned by its creator" + ); + apply_image(raw, image, 192).unwrap(); + let next = ICONS.lock().unwrap().get(&raw).unwrap().clone(); + assert_eq!(get(ICON_SMALL), next.small); + assert_eq!(get(ICON_BIG), next.big); + assert!(!exists(first.small)); + assert!(!exists(first.big)); + drop(window); + assert_eq!(tray_icon(raw), None); + assert!(!exists(next.small)); + assert!(!exists(next.big)); + assert!(exists(borrowed.0)); + } + + #[test] + fn application_icons_use_true_color_alpha_bitmaps_at_each_native_size() { + use windows::Win32::{ + Graphics::Gdi::{BITMAP, GetObjectW}, + UI::WindowsAndMessaging::GetIconInfo, + }; + let image = image::RgbaImage::from_fn(64, 64, |x, y| { + image::Rgba([x as u8 * 3, y as u8 * 3, 123, (x + y) as u8 * 2]) + }); + for size in [16, 24, 32, 48, 64] { + let icon = create(&image, size).unwrap(); + let mut info = ICONINFO::default(); + unsafe { + GetIconInfo(HICON(icon as *mut _), &mut info).unwrap(); + let mut bitmap = BITMAP::default(); + assert_ne!( + GetObjectW( + info.hbmColor.into(), + std::mem::size_of::() as i32, + Some((&mut bitmap as *mut BITMAP).cast()) + ), + 0 + ); + let _ = DeleteObject(info.hbmColor.into()); + let _ = DeleteObject(info.hbmMask.into()); + let _ = DestroyIcon(HICON(icon as *mut _)); + assert_eq!(bitmap.bmBitsPixel, 32, "palette/XOR icon at {size}"); + assert_eq!( + (bitmap.bmWidth, bitmap.bmHeight), + (size as i32, size as i32) + ); + } + } + } +} + +// Adapted Pealayer taskbar_preview.rs::bitmap (361..393). The application +// has no media preview module; retain its premultiplied top-down BGRA DIB. +fn bitmap( + image: &image::RgbaImage, +) -> windows::core::Result { + use windows::Win32::Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDIBSection, DIB_RGB_COLORS, + }; + let mut bits = std::ptr::null_mut(); + let info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: image.width() as i32, + biHeight: -(image.height() as i32), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let bitmap = unsafe { CreateDIBSection(None, &info, DIB_RGB_COLORS, &mut bits, None, 0)? }; + let bgra = unsafe { std::slice::from_raw_parts_mut(bits.cast::(), image.as_raw().len()) }; + for (src, dst) in image.as_raw().chunks_exact(4).zip(bgra.chunks_exact_mut(4)) { + let a = src[3] as u16; + dst.copy_from_slice(&[ + (src[2] as u16 * a / 255) as u8, + (src[1] as u16 * a / 255) as u8, + (src[0] as u16 * a / 255) as u8, + src[3], + ]); + } + Ok(bitmap) +} diff --git a/desktop/src/realtime.rs b/desktop/src/realtime.rs index 7be4c33d..45d1642f 100644 --- a/desktop/src/realtime.rs +++ b/desktop/src/realtime.rs @@ -15,15 +15,22 @@ pub struct Intent { pub batch: Option, pub at: Instant, pub pixel_request: Option, + pub correction_request: Option, path: &'static str, form: Vec<(String, String)>, + minigame_revision: Option, } pub fn supported(status: &DeviceStatus, path: &str) -> bool { - (path != "/api/game/reset" + (!crate::minigame::path(path) || crate::minigame::ready(status)) + && (path != "/api/game/reset" || status .capabilities .as_ref() .is_some_and(|c| c.has("reset-ack"))) + && (path != crate::pixel_corrections::PREVIEW_PATH || status.capabilities.as_ref() + .is_some_and(|c|c.has(crate::pixel_corrections::PREVIEW_COMMAND_FEATURE))) + && (!matches!(path,"/api/audio"|"/api/buzzer"|"/api/outputs") || status.avr_online + && status.capabilities.as_ref().is_some_and(|c|c.has("avr-command-ack"))) && status.command_clock_ms.is_some() && status .capabilities @@ -37,6 +44,9 @@ pub fn supported(status: &DeviceStatus, path: &str) -> bool { | "/api/game/reset" | "/api/game/settings" | "/api/game/press" + | crate::minigame::CONFIGURE + | crate::minigame::CONTROL + | crate::minigame::INPUT | "/api/cells/assignments" | "/api/cells/lock" | "/api/cell" @@ -45,9 +55,18 @@ pub fn supported(status: &DeviceStatus, path: &str) -> bool { | "/api/brightness" | "/api/led/output" | "/api/led/diagnostic" + | "/api/audio" + | "/api/buzzer" + | "/api/outputs" + | crate::pixel_corrections::PREVIEW_PATH ) } +pub struct VerifiedReceipt { + pub status: DeviceStatus, + pub corrections: Option, +} + pub fn older_than(value: &DeviceStatus, current: Option<&DeviceStatus>) -> bool { current.is_some_and(|old| !value.board_id.is_empty() && !value.boot_id.is_empty() && value.board_id==old.board_id && value.boot_id==old.boot_id && @@ -79,6 +98,7 @@ impl Intent { } let peer = peer_http::Pin::from_status(status) .ok_or("Realtime command requires a pinned board")?; + if crate::minigame::path(path) { crate::minigame::validate(status,path,&form)?; } if path == "/api/led/output" && let Some((_, backend)) = form.iter().find(|(key, _)| key == "backend") { if !crate::led_backend::allowed(status, backend) { return Err("LED backend unavailable or switching; nothing sent".into()); @@ -88,6 +108,20 @@ impl Intent { return Err("Command owner retired".into()); } let id = uuid::Uuid::new_v4().simple().to_string(); + let correction_request = if path == crate::pixel_corrections::PREVIEW_PATH { + Some(crate::pixel_corrections::PreviewRequest::from_form(status,&form)?) + } else { None }; + if matches!(path,"/api/audio"|"/api/buzzer"|"/api/outputs") { + let get=|name:&str|form.iter().find(|(key,_)|key==name).map(|(_,value)|value.as_str()); + let valid=match path { + "/api/audio"=>form.len()==1 && get("cue").is_some_and(|cue|status.audio_cues.iter().any(|available|available==cue)), + "/api/buzzer"=>form.len()==2 && get("frequency").is_some_and(|value|value.parse::().is_ok_and(|hz|(20..=12000).contains(&hz))) + && get("duration_ms").is_some_and(|value|value.parse::().is_ok_and(|ms|(1..=5000).contains(&ms))), + "/api/outputs"=>form.len()==1 && get("mask").is_some_and(|value|value.parse::().is_ok()), + _=>false, + }; + if !valid {return Err("Invalid or unadvertised AVR command; nothing sent".into());} + } if path == "/api/game/press" && (form.len() != 1 || form[0].0 != "key" || !form[0].1.parse::().is_ok_and(|key| (1..=9).contains(&key))) { return Err("Invalid key press".into()); @@ -137,6 +171,9 @@ impl Intent { } } let command = match path { + crate::minigame::CONFIGURE => "minigame-configure", + crate::minigame::CONTROL => "minigame-control", + crate::minigame::INPUT => "minigame-input", "/api/led/presentation" => "state-presentation", "/api/led/preview" => "color-preview", "/api/game/press" => "game-press", @@ -150,16 +187,24 @@ impl Intent { "/api/mode" => "mode", "/api/brightness" => "brightness", "/api/led/diagnostic" => "pixel-diagnostic", + "/api/audio" => "audio", + "/api/buzzer" => "buzzer", + "/api/outputs" => "outputs", + crate::pixel_corrections::PREVIEW_PATH => "pixel-corrections-preview", _ => "led-output", }; let mut value = serde_json::json!({"type":"command","request_id":id,"board_id":status.board_id, "boot_id":status.boot_id,"expires_at_ms":status.command_clock_ms.unwrap().checked_add(8000).ok_or("Command clock overflow")?,"command":command}); for (key, item) in &form { - value[key] = if key == "settings" && path == "/api/led/presentation" { + value[key] = if key == "configuration" && path == crate::pixel_corrections::PREVIEW_PATH { + correction_request.as_ref().ok_or("Missing correction preview")?.payload() + } else if key == "settings" && path == crate::minigame::CONFIGURE { + crate::minigame::settings(item,&status.minigame)? + } else if key == "settings" && path == "/api/led/presentation" { crate::state_appearance::parse(item)? } else if key == "presentation" { crate::game_presentation::parse(item)? - } else if key == "key" && path == "/api/game/press" { + } else if key == "key" && matches!(path,"/api/game/press" | crate::minigame::INPUT) { serde_json::json!(item.parse::().ok().filter(|key| (1..=9).contains(key)).ok_or("Invalid key")?) } else if key == "assignments" { let assignments: Vec = @@ -169,6 +214,8 @@ impl Intent { ) .map_err(|e| e.to_string())? } else if key == "id" + || path == "/api/buzzer" && matches!(key.as_str(), "frequency"|"duration_ms") + || path == "/api/outputs" && key == "mask" || path == "/api/led/preview" && matches!(key.as_str(), "rgb" | "duration_ms") || path == "/api/brightness" && key == "value" || path == "/api/game/settings" @@ -207,8 +254,10 @@ impl Intent { batch, at: Instant::now(), pixel_request: None, + correction_request, path, form, + minigame_revision: if crate::minigame::path(path) {status.minigame["revision"].as_u64()} else {None}, }) } pub fn admit(&self) -> Result<(), String> { @@ -220,7 +269,7 @@ impl Intent { } self.scope.admit() } - pub fn verify(&self, value: serde_json::Value) -> Result { + pub fn verify(&self, value: serde_json::Value) -> Result { if value["type"] != "command-result" || value["request_id"] != self.id || !self.peer.matches_identity( @@ -231,9 +280,9 @@ impl Intent { return Err("Foreign command receipt; nothing replayed".into()); } if value["accepted"] != true { - return Err(crate::cell_assignments::rejection( - value["error"].as_str().unwrap_or("unknown"), - )); + let reason=value["reason"].as_str().or_else(||value["error"].as_str()).unwrap_or("unknown"); + return Err(if self.correction_request.is_some() {crate::pixel_corrections::rejection(reason)} + else {crate::cell_assignments::rejection(reason)}); } let status: DeviceStatus = serde_json::from_value(value["status"].clone()) .map_err(|_| "Command receipt has no status readback")?; @@ -243,6 +292,28 @@ impl Intent { if peer_http::Pin::from_status(&status).as_ref() != Some(&self.peer) { return Err("Command readback belongs to another boot".into()); } + if crate::minigame::path(self.path) && !matches!( + (self.minigame_revision,status.minigame["revision"].as_u64()), + (Some(before),Some(after)) if after>=before) { + return Err("Game readback revision is missing or stale; nothing replayed".into()); + } + if let Some(request) = &self.correction_request { + self.admit()?; + let summary=&value["pixel_corrections_preview"]; + let lease=summary["lease_id"].as_u64().and_then(|v|u32::try_from(v).ok()); + if summary["base_revision"].as_u64()!=Some(u64::from(request.value.revision)) + || value["corrections_revision"].as_u64()!=Some(u64::from(request.value.revision)) + || if request.clear {summary["active"]!=false || lease!=Some(0)} else { + summary["active"]!=true || lease.is_none_or(|id|id==0 || request.lease!=0&&id!=request.lease) + || summary["remaining_ms"].as_u64().is_none_or(|ms|ms==0||ms>crate::pixel_corrections::PREVIEW_TTL_MS) + } { + return Err("Correction preview receipt differs; nothing replayed".into()); + } + let mut expected=request.clone(); + if !request.clear {expected.lease=lease.unwrap();} + let corrections=crate::pixel_corrections::read_preview(&self.endpoint,&self.peer,&expected,||self.admit())?; + return Ok(VerifiedReceipt{status,corrections:Some(corrections)}); + } let get = |key: &str| { self.form .iter() @@ -251,9 +322,16 @@ impl Intent { .unwrap_or_default() }; if self.path == "/api/led/output" && !get("backend").is_empty() { - return crate::led_backend::finish_network(&self.endpoint, status, get("backend"), || self.admit()); + return crate::led_backend::finish_network(&self.endpoint, status, get("backend"), || self.admit()) + .map(|status|VerifiedReceipt{status,corrections:None}); } let matches = match self.path { + crate::minigame::CONFIGURE | crate::minigame::CONTROL | crate::minigame::INPUT => + crate::minigame::verify(self.path,&self.form,&status), + "/api/audio"|"/api/buzzer"=>value["avr_acknowledged"]==true, + // ACK confirms the accepted command payload. active_outputs is a + // separate AVR snapshot and may legitimately arrive a frame later. + "/api/outputs"=>value["avr_acknowledged"]==true && value["acknowledged_mask"].as_u64()==get("mask").parse::().ok().map(u64::from), "/api/led/presentation" => crate::state_appearance::matches( &crate::state_appearance::parse(get("settings"))?, &status.state_presentation), "/api/led/preview" => if get("cancel")=="true" { @@ -307,10 +385,13 @@ impl Intent { if !matches { return Err("Command ACK received but readback differs; nothing replayed".into()); } - Ok(status) + Ok(VerifiedReceipt{status,corrections:None}) } - pub fn http(&self) -> Result { + pub fn http(&self) -> Result { self.admit()?; + if self.correction_request.is_some() { + return Err("Correction preview outcome unknown; no fallback mutation or command replayed".into()); + } let response = self .peer .client()? @@ -334,12 +415,21 @@ impl Intent { .map_err(|_| "Invalid command recovery receipt")?, ) } - pub fn emit(&self, events: &Sender, result: Result) { + pub fn emit(&self, events: &Sender, result: Result) { let send = |event| { let event = Event::ForScope(self.scope.clone(), Box::new(event)); let event = crate::for_endpoint(&self.endpoint, event); let _ = events.send(Event::ForConnection(self.stamp.clone(), Box::new(event))); }; + if let Some(request) = &self.correction_request { + let result=result.and_then(|receipt| { + send(Event::Status(receipt.status)); + receipt.corrections.ok_or_else(||"Missing correction preview readback".to_owned()) + }); + send(Event::CorrectionPreviewFinished(request.id,result)); + return; + } + let result=result.map(|receipt|receipt.status); if let Some(id) = self.pixel_request { if let Ok(status) = &result { send(Event::Status(status.clone())); @@ -405,6 +495,29 @@ mod tests { "boot_id":observed.boot_id,"accepted":true,"count":2,"status":observed}) } #[test] + fn minigame_intents_remain_typed_pinned_and_readback_verified() { + let mut current=status(); + current.capabilities.as_mut().unwrap().role="first-wins".into(); + current.minigame=serde_json::json!({"ready":true,"revision":1,"phase":"ready", + "available_modes":[{"id":"simon","label":"Simon"}],"settings":{"mode":"simon","rounds":5}}); + let gate=connection::Gate::default();let epoch=gate.select(Some(crate::Transport::WiFi)); + let (tx,_)=std::sync::mpsc::channel();let mut queue=peer_http::Queue::new(tx);queue.observe(¤t); + let form=vec![("settings".into(),"{\"rounds\":7}".into())]; + let request=Intent::new(endpoint(),¤t,crate::minigame::CONFIGURE,form, + queue.capture(),gate.stamp(crate::Transport::WiFi,epoch),None).unwrap(); + let packet:serde_json::Value=serde_json::from_str(&request.packet).unwrap(); + assert_eq!(packet["command"],"minigame-configure");assert_eq!(packet["settings"]["rounds"],7); + assert_eq!(packet["board_id"],current.board_id);assert!(packet["expires_at_ms"].as_u64().is_some()); + let mut result=serde_json::json!({"type":"command-result","request_id":request.id, + "board_id":current.board_id,"boot_id":current.boot_id,"accepted":true,"status":current}); + assert!(request.verify(result.clone()).is_err()); + result["status"]["minigame"]["settings"]["rounds"]=serde_json::json!(7); + assert!(request.verify(result.clone()).is_ok()); + result["boot_id"]=serde_json::json!("foreign");assert!(request.verify(result).is_err()); + current.minigame["ready"]=serde_json::json!(false); + assert!(!supported(¤t,crate::minigame::CONTROL)); + } + #[test] fn game_settings_use_typed_websocket_intent_and_selected_field_readback() { let mut current = status(); current.pixels = 216; @@ -475,6 +588,95 @@ mod tests { password: String::new(), } } + fn correction_intent() -> (Intent,DeviceStatus,connection::Gate,peer_http::Queue) { + let mut current=status();current.pixels=6; + current.capabilities.as_mut().unwrap().acknowledged_features.extend([ + crate::pixel_corrections::PREVIEW_FEATURE.into(),crate::pixel_corrections::PREVIEW_COMMAND_FEATURE.into()]); + let gate=connection::Gate::default();let epoch=gate.select(Some(crate::Transport::WiFi)); + let (tx,_)=std::sync::mpsc::channel();let mut queue=peer_http::Queue::new(tx);queue.observe(¤t); + let form=vec![("configuration".into(),serde_json::json!({"revision":7,"enabled":true, + "rules":[{"kind":"black","pixel":0,"offset":0,"enabled":true}],"lease_id":0,"ttl_ms":10000}).to_string())]; + let mut request=Intent::new(endpoint(),¤t,crate::pixel_corrections::PREVIEW_PATH,form, + queue.capture(),gate.stamp(crate::Transport::WiFi,epoch),None).unwrap(); + request.correction_request.as_mut().unwrap().id=31; + (request,current,gate,queue) + } + fn correction_receipt(request:&Intent,status:&DeviceStatus) -> serde_json::Value { + serde_json::json!({"type":"command-result","request_id":request.id,"accepted":true, + "board_id":status.board_id,"boot_id":status.boot_id,"status":status,"corrections_revision":7, + "pixel_corrections_preview":{"active":true,"lease_id":42,"base_revision":7,"remaining_ms":9500}}) + } + #[test] + fn pixel_corrections_realtime_typed_receipt_requires_authoritative_mapping() { + let (mut request,current,_gate,_queue)=correction_intent(); + let packet:serde_json::Value=serde_json::from_str(&request.packet).unwrap(); + assert_eq!(packet["command"],"pixel-corrections-preview"); + assert!(packet["configuration"].is_object());assert_eq!(packet["configuration"]["rules"][0]["pixel"],0); + let config=serde_json::json!({"revision":7,"enabled":true,"rules":[],"pixels":6,"sources":[0,1,2,3,4,5], + "active_sources":[-1,1,2,3,4,5],"preview":{"active":true,"lease_id":42,"base_revision":7,"remaining_ms":9000}}); + let (endpoint,server)=crate::tests::mock_device(vec![(200,serde_json::to_vec(&config).unwrap()), + (200,serde_json::to_vec(¤t).unwrap())]);request.endpoint=endpoint; + let result=request.verify(correction_receipt(&request,¤t)); + assert!(result.is_ok()); + let (tx,rx)=std::sync::mpsc::channel();request.emit(&tx,result); + let unwrap=|mut event:Event|{loop{event=match event { + Event::ForScope(_,inner)|Event::ForEndpoint(_,inner)|Event::ForConnection(_,inner)=>*inner, + other=>break other, + }} }; + assert!(matches!(unwrap(rx.recv().unwrap()),Event::Status(_))); + assert!(matches!(unwrap(rx.recv().unwrap()),Event::CorrectionPreviewFinished(31,Ok(value)) if value.preview.lease_id==42)); + assert!(rx.try_recv().is_err(),"No repetitive success toast for each preview renewal"); + let requests=server.join().unwrap();assert_eq!(requests.len(),2); + assert!(requests[0].starts_with("GET /api/pixels/corrections ")); + assert!(requests.iter().all(|request|request.starts_with("GET "))); + } + #[test] + fn pixel_corrections_realtime_rejection_or_foreign_lease_never_falls_back() { + let (mut request,current,_gate,_queue)=correction_intent(); + let mut rejected=correction_receipt(&request,¤t);rejected["accepted"]=false.into(); + rejected["reason"]="corrections_preview_owned".into(); + assert!(request.verify(rejected).err().unwrap().contains("corrections_preview_owned")); + assert!(request.http().err().unwrap().contains("no fallback mutation")); + request.correction_request.as_mut().unwrap().lease=41; + assert!(request.verify(correction_receipt(&request,¤t)).err().unwrap().contains("receipt differs")); + let mut unsupported=current.clone();unsupported.capabilities.as_mut().unwrap().acknowledged_features + .retain(|feature|feature!=crate::pixel_corrections::PREVIEW_COMMAND_FEATURE); + assert!(!supported(&unsupported,crate::pixel_corrections::PREVIEW_PATH)); + } + #[test] + fn realtime_avr_fast_commands_require_typed_bounds_and_actual_ack() { + let mut current=status();current.avr_online=true;current.audio_cues=vec!["winner".into()];current.active_outputs=5; + current.capabilities.as_mut().unwrap().acknowledged_features.extend(["buzzer".into(),"outputs".into(),"avr-command-ack".into()]); + let gate=connection::Gate::default();let epoch=gate.select(Some(crate::Transport::WiFi)); + let (tx,_)=std::sync::mpsc::channel();let mut queue=peer_http::Queue::new(tx);queue.observe(¤t); + for (path,form,command) in [ + ("/api/audio",vec![("cue".into(),"winner".into())],"audio"), + ("/api/buzzer",vec![("frequency".into(),"1000".into()),("duration_ms".into(),"70".into())],"buzzer"), + ("/api/outputs",vec![("mask".into(),"5".into())],"outputs"), + ] { + let request=Intent::new(endpoint(),¤t,path,form.clone(),queue.capture(),gate.stamp(crate::Transport::WiFi,epoch),None).unwrap(); + let packet:serde_json::Value=serde_json::from_str(&request.packet).unwrap();assert_eq!(packet["command"],command); + if path=="/api/buzzer" {assert_eq!(packet["frequency"],1000);assert_eq!(packet["duration_ms"],70);} + if path=="/api/outputs" {assert_eq!(packet["mask"],5);} + let mut receipt=serde_json::json!({"type":"command-result","request_id":request.id,"accepted":true, + "board_id":current.board_id,"boot_id":current.boot_id,"status":current}); + assert!(request.verify(receipt.clone()).is_err());receipt["avr_acknowledged"]=true.into(); + if path=="/api/outputs" { + assert!(request.verify(receipt.clone()).is_err()); + receipt["acknowledged_mask"]=5.into(); + receipt["status"]["active_outputs"]=0.into(); // A later snapshot updates the display. + } + assert!(request.verify(receipt).is_ok()); + let mut bad=form;bad[0].1="invalid".into(); + assert!(Intent::new(endpoint(),¤t,path,bad,queue.capture(),gate.stamp(crate::Transport::WiFi,epoch),None).is_err()); + let mut old=current.clone();old.capabilities.as_mut().unwrap().acknowledged_features.retain(|feature|feature!="avr-command-ack"); + assert!(!supported(&old,path)); + } + for (frequency,duration) in [(19,70),(12001,70),(1000,0),(1000,5001)] { + assert!(Intent::new(endpoint(),¤t,"/api/buzzer",vec![("frequency".into(),frequency.to_string()), + ("duration_ms".into(),duration.to_string())],queue.capture(),gate.stamp(crate::Transport::WiFi,epoch),None).is_err()); + } + } #[test] fn realtime_pixel_diagnostic_requires_exact_complete_readback_and_typed_intent() { let (mut request, _, _) = intent(endpoint()); @@ -516,7 +718,7 @@ mod tests { fn realtime_ack_requires_id_boot_count_and_every_selected_value() { let (request, _, _) = intent(endpoint()); let good = receipt(&request); - assert_eq!(request.verify(good.clone()).unwrap().cells[79], "red"); + assert_eq!(request.verify(good.clone()).unwrap().status.cells[79], "red"); for field in ["request_id", "board_id", "boot_id"] { let mut bad = good.clone(); bad[field] = serde_json::json!("foreign"); @@ -554,7 +756,7 @@ mod tests { let body = serde_json::to_vec(&receipt(&request)).unwrap(); let (endpoint, server) = crate::tests::mock_device(vec![(200, body)]); request.endpoint = endpoint; - assert_eq!(request.http().unwrap().cells[79], "red"); + assert_eq!(request.http().unwrap().status.cells[79], "red"); let requests = server.join().unwrap(); assert_eq!(requests.len(), 1); assert!(requests[0].starts_with("POST /api/command ")); diff --git a/desktop/src/state_appearance.rs b/desktop/src/state_appearance.rs index 0cb7d8c1..705b6e68 100644 --- a/desktop/src/state_appearance.rs +++ b/desktop/src/state_appearance.rs @@ -13,6 +13,7 @@ pub fn parse(text: &str) -> Result { for (key, value) in fields { let valid = match key.as_str() { "enabled" | "neighbors" | "invert" | "diagonal" => value.is_boolean(), + "fire_rotation" => value.as_u64().is_some_and(|n| matches!(n, 0 | 90 | 180 | 270)), "fade_ms" => value.as_u64().is_some_and(|n| n <= 10000), "pulse_ms" => value.as_u64().is_some_and(|n| (100..=10000).contains(&n)), "ring_ms" | "chase_ms" => value.as_u64().is_some_and(|n| (100..=20000).contains(&n)), @@ -58,6 +59,20 @@ pub fn controls(ui: &mut egui::Ui, current: &Value, table: bool) -> Option Option { let mut patch = None; + // Readback or opening this menu is not an effect-selection intent. + if let Some(saved) = current["fire_rotation"].as_u64().filter(|n| table && matches!(n, 0 | 90 | 180 | 270)) { + let mut rotation = saved; + dialog::setting_row(ui, icons::FIRE, "Fire direction", None, |ui| { + crate::ui::dropdown::ComboBox::from_id_salt("fire-direction") + .width(ui.available_width().clamp(100.0, 220.0)) + .selected_text(fire_direction(rotation)) + .show_ui(ui, |ui| {for angle in [0, 90, 180, 270] { + ui.dropdown_value(&mut rotation, angle, fire_direction(angle)); + }}); + }); + if rotation != saved { patch = Some(json!({"fire_rotation": rotation})); } + ui.add_space(8.0); + } let mut enabled = current["enabled"].as_bool()?; if ui .checkbox(&mut enabled, "Extended state effects") @@ -181,6 +196,9 @@ fn controls_inner(ui: &mut egui::Ui, current: &Value, table: bool) -> Option &'static str { + match rotation {90 => "90° · Right", 180 => "180° · Down", 270 => "270° · Left", _ => "0° · Up"} +} pub fn preview_controls(ui: &mut egui::Ui, ids: &str) -> Option> { ui.scope(|ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); @@ -321,7 +339,7 @@ mod tests { "idle":{"rgb":16777215,"motion":0},"won":{"rgb":65280,"motion":2}, "lost":{"rgb":16711680,"motion":1},"off":{"rgb":0,"motion":0}}, "fade_ms":250,"pulse_ms":900,"ring_ms":900,"chase_ms":1800, - "neighbors":true,"invert":false,"diagonal":true}); + "neighbors":true,"invert":false,"diagonal":true,"fire_rotation":180}); for width in [220.0, 280.0, 380.0, 640.0] { for table in [false, true] { let context = egui::Context::default(); @@ -359,6 +377,11 @@ mod tests { } #[test] fn validates_and_compares_only_requested_fields() { + for angle in [0, 90, 180, 270] { + let patch = parse(&format!(r#"{{"fire_rotation":{angle}}}"#)).unwrap(); + assert!(matches(&patch, &json!({"fire_rotation":angle,"enabled":false}))); + assert!(!matches(&patch, &json!({"fire_rotation":(angle+90)%360}))); + } let p = parse(r#"{"states":{"lost":{"rgb":255}},"enabled":true}"#).unwrap(); assert!(matches( &p, @@ -373,6 +396,9 @@ mod tests { r#"{"ring_ms":0}"#, r#"{"enabled":1}"#, r#"{"states":{"lost":{"motion":4}}}"#, + r#"{"fire_rotation":45}"#, + r#"{"fire_rotation":360}"#, + r#"{"fire_rotation":"90"}"#, ] { assert!(parse(invalid).is_err()); } diff --git a/desktop/src/ui/button_geometry_tests.rs b/desktop/src/ui/button_geometry_tests.rs index f997f7d7..64d3ee96 100644 --- a/desktop/src/ui/button_geometry_tests.rs +++ b/desktop/src/ui/button_geometry_tests.rs @@ -4,7 +4,7 @@ //! paint-only states must not move either the hit target or the caption ink. use eframe::egui; -const CAPTIONS: [&str; 4] = ["Ordinary", "Selectable", "Apply", "Primary"]; +const CAPTIONS: [&str; 5] = ["Ordinary", "Selectable", "Apply", "Primary", "Arrange boxes"]; #[derive(Debug)] struct FrameGeometry { @@ -92,7 +92,8 @@ fn draw_frame( ui.add(button) } 2 => super::dialog::action_button(ui, "+", CAPTIONS[index]), - _ => super::dialog::primary_action_button(ui, "+", CAPTIONS[index]), + 3 => super::dialog::primary_action_button(ui, "+", CAPTIONS[index]), + _ => super::key_boxes::arrange_control(ui, selected), }) .inner; geometry.rects.push(response.rect); @@ -114,7 +115,7 @@ fn draw_frame( assert_eq!(geometry.text_origins.len(), CAPTIONS.len()); if menu_scope { let ordinary = geometry.rects[0]; - for action in &geometry.rects[1..] { + for action in &geometry.rects[1..4] { assert_eq!( action.height(), ordinary.height(), diff --git a/desktop/src/ui/cell_table.rs b/desktop/src/ui/cell_table.rs index 4530e985..231d41bc 100644 --- a/desktop/src/ui/cell_table.rs +++ b/desktop/src/ui/cell_table.rs @@ -68,7 +68,7 @@ impl Selection { self.ids = ids; } } - fn marquee(&mut self, rect: Rect, cells: &[(u8, Rect)], base: &BTreeSet) { + pub(super) fn marquee(&mut self, rect: Rect, cells: &[(u8, Rect)], base: &BTreeSet) { let previous = self.ids.clone(); self.ids = base.clone(); self.ids.extend( diff --git a/desktop/src/ui/connection_controls.rs b/desktop/src/ui/connection_controls.rs index 44a32f6e..ed343c10 100644 --- a/desktop/src/ui/connection_controls.rs +++ b/desktop/src/ui/connection_controls.rs @@ -71,11 +71,17 @@ pub struct State<'a> { pub method: Transport, pub enabled: bool, pub live: bool, + pub peer_known: bool, pub endpoint: &'a str, pub port: &'a str, pub identity_pending: bool, } +pub fn phase_label(enabled: bool, live: bool, peer_known: bool) -> &'static str { + if live { "Connected" } else if !enabled { "Disconnected" } + else if peer_known { "Reconnecting…" } else { "Connecting…" } +} + pub struct Responses { pub dropdown: egui::Response, pub lifecycle: egui::Response, @@ -94,6 +100,8 @@ pub fn draw( ui.visuals().dark_mode, "green", ) + } else if state.enabled { + ui.visuals().warn_fg_color } else { ui.visuals().text_color() }; @@ -107,13 +115,7 @@ pub fn draw( |ui| { dropdown::menu_ui(ui, owner, |ui| { ui.set_min_width(240.0); - ui.strong(if state.live { - "Connected" - } else if state.enabled { - "Connecting…" - } else { - "Disconnected" - }); + ui.colored_label(color, super::i18n::text(phase_label(state.enabled, state.live, state.peer_known))); ui.weak(match state.method { Transport::WiFi => state.endpoint, Transport::Serial if state.port.is_empty() => "No port selected", @@ -161,14 +163,16 @@ pub fn draw( Transport::WiFi => !state.endpoint.trim().is_empty(), Transport::Serial => !state.port.trim().is_empty(), }; + let waiting = state.enabled && !state.live; + let label = if waiting { phase_label(true, false, state.peer_known) } + else if state.enabled { "Disconnect" } else { "Connect" }; let lifecycle = ui.add_enabled( !state.identity_pending && (state.enabled || endpoint_present), - egui::Button::new(if state.enabled { - "Disconnect" - } else { - "Connect" - }), - ); + egui::Button::new(egui::RichText::new(format!("{} {}", + if waiting { icons::ARROW_CLOCKWISE } else if state.enabled { icons::X } else { icons::PLUG }, + super::i18n::text(label))) + .color(if waiting { ui.visuals().warn_fg_color } else { ui.visuals().text_color() })), + ).on_hover_text(if waiting { "Cancel reconnecting" } else { label }); if lifecycle.clicked() { actions.push(Action::Lifecycle(!state.enabled)); } @@ -280,6 +284,7 @@ mod tests { method, enabled: false, live: false, + peer_known: false, endpoint: "http://ledboard.local", port: "COM7", identity_pending: busy, @@ -389,6 +394,7 @@ mod tests { method: Transport::Serial, enabled, live: enabled, + peer_known: enabled, endpoint: "", port: "COM7", identity_pending: false, @@ -422,4 +428,34 @@ mod tests { assert_eq!(actions, vec![Action::Lifecycle(!enabled)]); } } + + #[test] + fn reconnecting_is_warning_colored_and_cancellable_not_connected() { + assert_eq!(phase_label(true, false, true), "Reconnecting…"); + assert_eq!(phase_label(true, false, false), "Connecting…"); + assert_eq!(phase_label(false, false, true), "Disconnected"); + let ctx = egui::Context::default(); + let mut actions = Vec::new(); + let mut response = None; + let mut render = |events| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + response = Some(draw(ui, State { method: Transport::WiFi, enabled: true, live: false, + peer_known: true, endpoint: "http://keycontroller.local", port: "", identity_pending: false }, + || panic!("Network lifecycle must not scan USB"), &mut actions)); + }); + out.textures_delta.clear(); + (response.as_ref().unwrap().lifecycle.rect, text(&out), out) + }; + let (rect, labels, output) = render(vec![]); + assert!(labels.iter().any(|label| label.contains("Reconnecting…"))); + assert!(!labels.iter().any(|label| label == "Disconnect")); + let warn = ctx.style_of(ctx.theme()).visuals.warn_fg_color; + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, + egui::epaint::Shape::Text(t) if t.galley.job.text.contains("Reconnecting…") + && t.galley.job.sections.iter().any(|section| section.format.color == warn)))); + for pressed in [true, false] { render(vec![egui::Event::PointerMoved(rect.center()), egui::Event::PointerButton { + pos: rect.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE + }]); } + assert_eq!(actions, vec![Action::Lifecycle(false)]); + } } diff --git a/desktop/src/ui/device_settings.rs b/desktop/src/ui/device_settings.rs index 3f6981cb..7e0333e7 100644 --- a/desktop/src/ui/device_settings.rs +++ b/desktop/src/ui/device_settings.rs @@ -258,6 +258,21 @@ fn secret(ui: &mut egui::Ui, id: &str, label: &str, stored: bool, value: &mut Se }); } fn network_page(app: &mut PakarControllerApp, draft: &mut Editor, ui: &mut egui::Ui, ready: bool) { + if draft.scan_pending && app.command_route() == Some(Transport::WiFi) { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(500)); + let now = std::time::Instant::now(); + if draft.scan_poll_due(now) { + if ready { + let _ = app.http_tx.send(HttpCommand::Configuration( + app.endpoint(), + "/api/wifi/scan?results=true", + )); + } else { + draft.scan_retry_at = Some(now + std::time::Duration::from_millis(500)); + } + } + } if app.command_route() == Some(Transport::Serial) { dialog::section(ui, icons::PLUG, "USB network provisioning", |ui| { ui.label("USB can provision Wi-Fi, but cannot read saved network settings. This is a NEW provisioning request, not observed device configuration. Verify via HTTP afterward."); @@ -348,6 +363,8 @@ fn network_page(app: &mut PakarControllerApp, draft: &mut Editor, ui: &mut egui: .clicked() { draft.scan_pending = true; + draft.scan_retry_at = None; + draft.scan_deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(15)); let _ = app.http_tx.send(HttpCommand::Configuration( app.endpoint(), "/api/wifi/scan", @@ -802,7 +819,6 @@ fn status_rgb_page( { submit(app, draft, Group::StatusRgb); } - }); } fn maintenance_page( diff --git a/desktop/src/ui/dialog.rs b/desktop/src/ui/dialog.rs index 862b5bff..b37b5f2f 100644 --- a/desktop/src/ui/dialog.rs +++ b/desktop/src/ui/dialog.rs @@ -167,6 +167,15 @@ fn action_button_with_kind( let text = egui::RichText::new(format!("{icon} {}", crate::ui::i18n::text(label))) .font(egui::TextStyle::Button.resolve(ui.style())); ui.scope(|ui| { + // Every action, not only primary actions, inherits one fixed frame. + // The donor fix forbids hover borders/expansion from changing geometry. + let radius = ui.visuals().widgets.inactive.corner_radius; + let widgets = &mut ui.style_mut().visuals.widgets; + for visuals in [&mut widgets.inactive, &mut widgets.hovered, &mut widgets.active, &mut widgets.open] { + visuals.bg_stroke.width = border_width; + visuals.corner_radius = radius; + visuals.expansion = 0.0; + } // Adapt Pealayer's contextual_transport_style: semantic ink/fill // belongs to the enabled action, including hover and press, while // disabled controls retain the host's actual disabled appearance. diff --git a/desktop/src/ui/host_wifi.rs b/desktop/src/ui/host_wifi.rs new file mode 100644 index 00000000..e8c23f48 --- /dev/null +++ b/desktop/src/ui/host_wifi.rs @@ -0,0 +1,121 @@ +//! Uses the existing fixed-geometry dialog/dropdown controls; never joins while painting. +use super::{dialog, dropdown, dropdown::DropdownUiExt, icons}; +use crate::platform::host_wifi::{self, Profile}; +use eframe::egui; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct State { + profiles: Vec, + selected: Option, + busy: bool, + message: String, + confirm: bool, +} +enum Action { + Refresh, + Join(Profile), + Settings, +} + +pub fn draw(ui: &mut egui::Ui, enabled: bool) { + let id = egui::Id::new("host-wifi-controls"); + let shared = ui.ctx().data_mut(|data| { + data.get_temp_mut_or_default::>>(id) + .clone() + }); + let mut action = None; + let Ok(mut state) = shared.lock() else { + return; + }; + ui.collapsing(format!("{} This PC’s Wi-Fi", icons::WIFI_HIGH), |ui| { + ui.weak("Join the board’s access point or its router. This changes the PC’s network, not board settings."); + ui.add_enabled_ui(enabled && !state.busy, |ui| { + dropdown::ComboBox::from_id_salt("host-wifi-profile").width(340.0) + .selected_text(state.selected.as_ref().map(|p| p.name.as_str()).unwrap_or("Select a saved Wi-Fi profile")) + .show_ui(ui, |ui| { + for profile in state.profiles.clone() { + let label = format!("{} · {}", profile.name, profile.adapter); + ui.dropdown_value(&mut state.selected, Some(profile), label); + } + }); + ui.horizontal_wrapped(|ui| { + if dialog::action_button(ui, icons::ARROW_CLOCKWISE, "Refresh profiles").clicked() { action = Some(Action::Refresh); } + if dialog::action_button(ui, icons::GEAR, "Wi-Fi settings…").clicked() { action = Some(Action::Settings); } + if ui.add_enabled_ui(state.selected.is_some(), |ui| dialog::primary_action_button(ui, icons::WIFI_HIGH, "Join…")).inner.clicked() { + state.confirm = true; + } + }); + if state.confirm { + ui.weak("Switch Wi-Fi now? Other network sessions may disconnect."); + ui.horizontal(|ui| { + if dialog::action_button(ui, icons::CHECK, "Switch network").clicked() { + if let Some(profile) = state.selected.clone() { action = Some(Action::Join(profile)); } + state.confirm = false; + } + if dialog::action_button(ui, icons::X, "Cancel").clicked() { state.confirm = false; } + }); + } + }); + if state.busy { ui.spinner(); } + if !state.message.is_empty() { ui.label(&state.message); } + }); + if let Some(action) = action { + state.busy = true; + state.message.clear(); + let shared = shared.clone(); + let ctx = ui.ctx().clone(); + // WLAN service calls never block the render/transport thread. + std::thread::spawn(move || { + let result = match action { + Action::Refresh => host_wifi::profiles().map(|profiles| { + ( + Some(profiles), + "Profiles read from Windows. New networks can be added in Wi-Fi settings." + .into(), + ) + }), + Action::Join(profile) => host_wifi::join(&profile).map(|message| (None, message)), + Action::Settings => host_wifi::settings().map(|message| (None, message)), + }; + if let Ok(mut state) = shared.lock() { + state.busy = false; + match result { + Ok((profiles, message)) => { + if let Some(profiles) = profiles { + if state + .selected + .as_ref() + .is_some_and(|p| !profiles.contains(p)) + { + state.selected = None; + } + state.profiles = profiles; + } + state.message = message; + } + Err(error) => state.message = error, + } + } + ctx.request_repaint(); + }); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn rendering_does_not_join_or_scan() { + let ctx = eframe::egui::Context::default(); + let _ = ctx.run_ui(Default::default(), |ui| super::draw(ui, true)); + let state = ctx + .data(|data| { + data.get_temp::>>(eframe::egui::Id::new( + "host-wifi-controls", + )) + }) + .unwrap(); + let state = state.lock().unwrap(); + assert!(!state.busy && state.profiles.is_empty() && !state.confirm); + } +} diff --git a/desktop/src/ui/i18n.rs b/desktop/src/ui/i18n.rs index 8bcfa584..f0d838c8 100644 --- a/desktop/src/ui/i18n.rs +++ b/desktop/src/ui/i18n.rs @@ -511,6 +511,10 @@ pub fn tr(language: AppLanguage, english: &str) -> String { } "Connect" => "اتصال", "Connecting…" => "در حال اتصال…", + "Reconnecting…" => "در حال اتصال دوباره…", + "Trigger box" => "فعال‌سازی جعبه", + "Copy box IDs" => "کپی شمارهٔ جعبه‌ها", + "Select enabled" => "انتخاب فعال‌ها", "Disconnect" => "قطع اتصال", "Hardware Disconnected" => "سخت‌افزار قطع است", "Read-only: firmware must advertise board/boot identity and HTTP fencing" => { diff --git a/desktop/src/ui/key_boxes.rs b/desktop/src/ui/key_boxes.rs index 3f2f0adb..4540480b 100644 --- a/desktop/src/ui/key_boxes.rs +++ b/desktop/src/ui/key_boxes.rs @@ -5,6 +5,7 @@ //! e47f7739 (stable source rect), d71c88d6 (capture before drag promotion). //! This is a scoped reuse, not a claim of full Designer/timeline parity. use super::i18n::text; +use super::{cell_table::Selection, dropdown::DropdownUiExt, icons}; use eframe::egui::{self, Color32, Pos2, Rect, Stroke, Vec2}; const GRID: f32 = 16.0; @@ -43,7 +44,90 @@ impl BoxView { #[derive(Default)] pub struct Output { pub trigger: Option, + pub blocked_trigger: Option, pub layout_changed: bool, + pub selection_changed: bool, + pub editing: Option, + pub panel: Option, +} + +// Adapt Pealayer layout.rs control_context_popup/draw_board_card_context_menu: +// one card-owned popup, muted identity, grouped actions and capability gates. +fn context_menu(ui: &mut egui::Ui, target: Option, selection: &mut Selection, + models: &[BoxView; 9], editing: bool, can_trigger: bool, out: &mut Output) { + // A custom full-width choice must measure against the menu, not inherit + // the designer canvas's width during the popup's initial sizing pass. + ui.set_max_width(240.0); + ui.set_width(220.0); + super::cell_table::compact_menu(ui); + ui.strong(target.map_or_else(|| text("Boxes"), |key| format!("{} {key}", text("Box")))); + ui.weak(format!("{} selected", selection.ids.len())); + ui.separator(); + if let Some(key) = target { + ui.add_enabled_ui(!editing && can_trigger && models[usize::from(key - 1)].enabled, |ui| { + if ui.dropdown_choice(false, format!("{} {}", icons::HAND_TAP, text("Trigger box"))).clicked() { + out.trigger = Some(key); + } + }); + ui.separator(); + } + super::menu::submenu(ui, format!("{} {}", icons::SELECTION_ALL, text("Selection")), |ui| { + if ui.add(egui::Button::new(format!("{} {}", icons::SELECTION_ALL, text("Select all"))).shortcut_text("Ctrl+A")).clicked() { + selection.group(1..=9, false); ui.close(); + } + if ui.button(format!("{} {}", icons::CHECK, text("Select enabled"))).clicked() { + selection.group((1..=9).filter(|key| models[usize::from(*key - 1)].enabled), false); ui.close(); + } + if ui.button(format!("{} {}", icons::SELECTION_ALL, text("Invert selection"))).clicked() { + let ids: Vec<_> = (1..=9).filter(|key| !selection.ids.contains(key)).collect(); + selection.group(ids, false); ui.close(); + } + if ui.add_enabled(!selection.ids.is_empty(), egui::Button::new(format!("{} {}", icons::X, text("Clear selection"))).shortcut_text("Esc")).clicked() { + selection.clear(); ui.close(); + } + }); + if ui.add_enabled(!selection.ids.is_empty(), egui::Button::new(format!("{} {}", icons::COPY, text("Copy box IDs")))).clicked() { + ui.ctx().copy_text(super::cell_search::format_selection(&selection.ids)); ui.close(); + } + ui.separator(); + if ui.dropdown_choice(editing, format!("{} {}", icons::DOTS_SIX_VERTICAL, text("Arrange boxes"))).clicked() { + out.editing = Some(!editing); + } + super::menu::submenu(ui, format!("{} {}", icons::TABS, text("Panels")), |ui| { + for (panel, icon, label) in [ + (super::layout::Panel::PixelDiagnostics, icons::TARGET, "Pixel diagnostics"), + (super::layout::Panel::EffectControls, icons::SPARKLE, "Output & effects"), + ] { + if ui.button(format!("{icon} {}", text(label))).clicked() { out.panel = Some(panel); ui.close(); } + } + }); +} + +/// Fixed allocation/ink origins: selection and pointer state affect color only. +/// Adapt Pealayer 5b3d5c1's inherited stroke/font contract without an atomic +/// Button/SelectableLabel frame calculating different margins per state. +pub fn arrange_control(ui: &mut egui::Ui, selected: bool) -> egui::Response { + let label = format!("{} {}", icons::DOTS_SIX_VERTICAL, text("Arrange boxes")); + let font = egui::TextStyle::Button.resolve(ui.style()); + let galley = ui.painter().layout_no_wrap(label.clone(), font, ui.visuals().text_color()); + let size = egui::vec2( + (galley.size().x + 2.0 * ui.spacing().button_padding.x).min(ui.available_width()), + super::control_density::height(ui), + ); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + response.widget_info(|| egui::WidgetInfo::selected( + egui::WidgetType::Button, ui.is_enabled(), selected, &label)); + let visuals = ui.style().interact_selectable(&response, selected); + let border = ui.visuals().widgets.inactive.bg_stroke.width; + let stroke = if response.has_focus() || selected { + Stroke::new(border, ui.visuals().selection.bg_fill) + } else { Stroke::new(border, visuals.bg_stroke.color) }; + ui.painter().rect(rect, ui.visuals().widgets.inactive.corner_radius, + visuals.weak_bg_fill, stroke, egui::StrokeKind::Inside); + ui.painter().with_clip_rect(rect.shrink(1.0)).galley( + egui::pos2(rect.left() + ui.spacing().button_padding.x, + rect.center().y - galley.size().y / 2.0), galley, visuals.text_color()); + response } #[derive(Clone, Copy)] @@ -52,6 +136,12 @@ struct Drag { grab_offset: Vec2, last_frame: u64, } +#[derive(Clone)] +struct Marquee { + origin: Pos2, + base: std::collections::BTreeSet, + last_frame: u64, +} fn snap(value: f32, maximum: f32) -> f32 { (value / GRID).round().clamp(0.0, (maximum / GRID).floor()) * GRID @@ -282,8 +372,10 @@ pub fn show( board_id: &str, positions: &mut [[f32; 2]; 9], models: &[BoxView; 9], + selection: &mut Selection, editing: bool, can_trigger: bool, + trigger_on_click: bool, animate: bool, ) -> Output { // A horizontal ScrollArea gives its child unbounded width; size against the @@ -292,14 +384,19 @@ pub fn show( egui::ScrollArea::horizontal() .id_salt(("key-box-scroll", board_id)) .show(ui, |ui| { - let (canvas, _) = - ui.allocate_exact_size(egui::vec2(canvas_width, HEIGHT), egui::Sense::hover()); + let (canvas, background) = + ui.allocate_exact_size(egui::vec2(canvas_width, HEIGHT), egui::Sense::click_and_drag()); ui.set_clip_rect(ui.clip_rect().intersect(canvas)); let inner = canvas.shrink(16.0); let rects = layout_rects(inner, positions); let state_id = ui.make_persistent_id(("key-box-drag", board_id)); let mut drag = ui.data_mut(|data| data.get_temp::(state_id)); + let marquee_id = state_id.with("selection"); + let mut marquee = ui.data_mut(|data| data.get_temp::(marquee_id)); let mut out = Output::default(); + let previous_selection = selection.ids.clone(); + let modifiers = ui.input(|input| input.modifiers); + let mut card_clicked = false; let current_frame = ui.ctx().cumulative_frame_nr(); let released = ui.input(|input| input.pointer.button_released(egui::PointerButton::Primary)); @@ -307,6 +404,19 @@ pub fn show( .ctx() .pointer_hover_pos() .or_else(|| released.then(|| ui.ctx().pointer_interact_pos()).flatten()); + if editing || !ui.input(|input| input.focused) || pointer.is_none() + || marquee.as_ref().is_some_and(|active| current_frame > active.last_frame.saturating_add(1)) { + marquee = None; + } + if marquee.is_some() && ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + selection.group(marquee.as_ref().unwrap().base.iter().copied(), false); + marquee = None; + } + if !editing && background.drag_started_by(egui::PointerButton::Primary) { + if let Some(origin) = ui.input(|input| input.pointer.press_origin()).filter(|p| canvas.contains(*p)) { + marquee = Some(Marquee { origin, base: if modifiers.command || modifiers.ctrl || modifiers.shift { selection.ids.clone() } else { Default::default() }, last_frame: current_frame }); + } + } if !editing || !ui.input(|input| input.focused) || (drag.is_some() @@ -378,9 +488,10 @@ pub fn show( }, ); response.widget_info(|| { - egui::WidgetInfo::labeled( + egui::WidgetInfo::selected( egui::WidgetType::Button, - editing || (can_trigger && models[index].enabled), + ui.is_enabled(), + selection.ids.contains(&key), format!( "{} {key}, {}, {} {}, {} {}", text("Box"), @@ -393,6 +504,18 @@ pub fn show( ) }); focus[index] = response.has_focus(); + if response.clicked() && marquee.is_none() { + selection.click(key, modifiers.command || modifiers.ctrl, modifiers.shift); + response.request_focus(); + card_clicked = true; + } + if response.secondary_clicked() && !selection.ids.contains(&key) { + selection.click(key, false, false); + } + let menu = egui::Popup::context_menu(&response).id(id.with("context")); + let shown = menu.show(|ui| super::dropdown::menu_ui(ui, id.with("context"), |ui| + context_menu(ui, Some(key), selection, models, editing, can_trigger, &mut out))); + if shown.is_none() { super::dropdown::clear_menu(ui.ctx(), id.with("context")); } hover[index] = if animate { ui.ctx() .animate_bool_with_time(id.with("hover"), response.hovered(), 0.12) @@ -455,8 +578,10 @@ pub fn show( } } } - } else if response.clicked() && can_trigger && models[index].enabled { - out.trigger = Some(key); + } else if response.clicked() && marquee.is_none() && trigger_on_click && models[index].enabled + && !modifiers.command && !modifiers.ctrl && !modifiers.shift { + if can_trigger { out.trigger = Some(key); } + else { out.blocked_trigger = Some(key); } } response .on_hover_cursor(if editing { @@ -508,7 +633,7 @@ pub fn show( (index + 1) as u8, &models[index], hover[index], - focus[index], + focus[index] || selection.ids.contains(&((index + 1) as u8)), editing, false, ); @@ -561,6 +686,19 @@ pub fn show( } ui.ctx().request_repaint(); } + if let (Some(active), Some(pointer)) = (&mut marquee, pointer) { + let area = Rect::from_two_pos(active.origin, pointer).intersect(canvas); + let targets: Vec<_> = rects.iter().enumerate().map(|(i, rect)| ((i + 1) as u8, *rect)).collect(); + selection.marquee(area, &targets, &active.base); + ui.painter().rect(area, 4.0, accent.gamma_multiply(0.12), Stroke::new(1.0, accent), egui::StrokeKind::Inside); + active.last_frame = current_frame; + if released || !ui.input(|input| input.pointer.primary_down()) { marquee = None; } + ui.ctx().request_repaint(); + } + ui.data_mut(|data| { + if let Some(active) = marquee { data.insert_temp(marquee_id, active); } + else { data.remove::(marquee_id); } + }); ui.data_mut(|data| { if let Some(mut drag) = drag { drag.last_frame = current_frame; @@ -569,6 +707,24 @@ pub fn show( data.remove::(state_id); } }); + let over_card = pointer.is_some_and(|p| rects.iter().any(|rect| rect.contains(p))); + if background.clicked() && !over_card && !card_clicked && !modifiers.command && !modifiers.ctrl { + selection.clear(); + } + if pointer.is_some_and(|p| canvas.contains(p)) + && (!ui.ctx().egui_wants_keyboard_input() || focus.iter().any(|value| *value)) { + if ui.input_mut(|input| input.consume_key(egui::Modifiers::COMMAND, egui::Key::A)) { + selection.group(1..=9, false); + } + if drag.is_none() && ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + selection.clear(); + } + } + if !over_card { + background.context_menu(|ui| super::dropdown::menu_ui(ui, state_id.with("context"), |ui| + context_menu(ui, None, selection, models, editing, can_trigger, &mut out))); + } + out.selection_changed = previous_selection != selection.ids; out }) .inner @@ -578,6 +734,89 @@ pub fn show( mod tests { use super::*; + #[test] + fn background_drag_selects_boxes_without_triggering_or_moving_them() { + let ctx = egui::Context::default(); + let mut positions = crate::native_workspace::default_key_positions(); + let original = positions; + let mut selection = Selection::default(); + let mut rects = [Rect::NOTHING; 9]; + let mut frame = |events| { + let mut result = Output::default(); + let mut rendered = ctx.run_ui(egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0))), + events, focused: true, ..Default::default() + }, |ui| { + ui.set_width(720.0); + rects = layout_rects(Rect::from_min_size(ui.cursor().min, egui::vec2(720.0, HEIGHT)).shrink(16.0), &positions); + result = show(ui, "marquee-fixture", &mut positions, &models(true), &mut selection, false, true, true, false); + }); + rendered.textures_delta.clear(); + assert!(result.trigger.is_none()); + assert!(!result.layout_changed); + (rects, selection.cells()) + }; + let (rects, _) = frame(vec![]); + let start = rects[0].min - egui::vec2(8.0, 8.0); + let end = rects[1].max + egui::vec2(4.0, 4.0); + frame(vec![egui::Event::PointerMoved(start), pointer_button(start, true)]); + let (_, ids) = frame(vec![egui::Event::PointerMoved(end)]); + assert_eq!(ids, vec![1, 2]); + let (_, ids) = frame(vec![pointer_button(end, false)]); + assert_eq!(ids, vec![1, 2]); + assert_eq!(positions, original); + } + + #[test] + fn box_clicks_reuse_selection_and_secondary_menu_never_actuates() { + let ctx = egui::Context::default(); + let mut selection = Selection::default(); + let mut positions = crate::native_workspace::default_key_positions(); + let model = models(true); + let mut frame = |mut events: Vec, modifiers| { + events.insert(0, egui::Event::ModifiersChanged(modifiers)); + let mut result = Output::default(); + let mut rects = [Rect::NOTHING; 9]; + let mut rendered = ctx.run_ui(egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0))), + events, focused: true, ..Default::default() + }, |ui| { + ui.set_width(720.0); + rects = layout_rects(Rect::from_min_size(ui.cursor().min, egui::vec2(720.0, HEIGHT)).shrink(16.0), &positions); + result = show(ui, "selection-fixture", &mut positions, &model, &mut selection, false, true, false, false); + }); + rendered.textures_delta.clear(); + let labels: Vec<_> = rendered.shapes.iter().filter_map(|shape| match &shape.shape { + egui::epaint::Shape::Text(text) => Some(text.galley.job.text.clone()), _ => None + }).collect(); + assert!(result.trigger.is_none(), "Selection and menus are not game presses"); + (rects, selection.cells(), labels) + }; + let (rects, _, _) = frame(vec![], egui::Modifiers::NONE); + for (index, modifiers, expected) in [ + (0, egui::Modifiers::NONE, vec![1]), + (2, egui::Modifiers::CTRL, vec![1, 3]), + (4, egui::Modifiers::SHIFT, vec![3, 4, 5]), + (3, egui::Modifiers::CTRL, vec![3, 5]), + ] { + let pos = rects[index].center(); + for pressed in [true, false] { + let (_, ids, _) = frame(vec![egui::Event::PointerMoved(pos), egui::Event::PointerButton { + pos, button: egui::PointerButton::Primary, pressed, modifiers + }], modifiers); + if !pressed { assert_eq!(ids, expected); } + } + } + let pos = rects[2].center(); + for pressed in [true, false] { + frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }], egui::Modifiers::NONE); + } + let (_, ids, labels) = frame(vec![], egui::Modifiers::NONE); + assert_eq!(ids, vec![3, 5]); + assert!(labels.iter().any(|label| label.contains("Selection")), "Expected real context menu: {labels:?}"); + assert!(labels.iter().any(|label| label.contains("Trigger box"))); + } + fn inner(width: f32) -> Rect { Rect::from_min_size(Pos2::ZERO, egui::vec2(width, HEIGHT)).shrink(16.0) } @@ -664,6 +903,51 @@ mod tests { }) } + #[test] + fn box_context_menu_does_not_inherit_the_canvas_width() { + for width in [448.0, 1200.0, 2400.0] { + let ctx = egui::Context::default(); + let mut menu_width = 0.0; + for _ in 0..3 { + let mut rendered = ctx.run_ui(egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(width, 700.0))), + ..Default::default() + }, |ui| { + ui.set_width(width); + egui::Area::new(egui::Id::new("box-menu-width")) + .order(egui::Order::Foreground).fixed_pos(egui::pos2(20.0, 20.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + context_menu(ui, Some(1), &mut Selection::default(), &models(true), + false, true, &mut Output::default()); + menu_width = ui.min_rect().width(); + }); + }); + }); + rendered.textures_delta.clear(); + } + assert!((220.0..=240.0).contains(&menu_width), "canvas={width}, menu={menu_width}"); + } + } + + #[test] + fn both_illumination_and_indicator_accept_exactly_one_press() { + for group in 0..2 { + let ctx = egui::Context::default(); + let mut positions = crate::native_workspace::default_key_positions(); + let models = models(true); + let (_, bounds) = frame(&ctx, &mut positions, &models, false, true, vec![]); + let card = layout_rects(bounds.shrink(16.0), &positions)[0]; + let point = card.min + egui::vec2(40.0, 69.0 + group as f32 * 24.0); + for events in [vec![egui::Event::PointerMoved(point)], vec![pointer_button(point, true)]] { + assert!(frame(&ctx, &mut positions, &models, false, true, events).0.trigger.is_none()); + } + assert_eq!(frame(&ctx, &mut positions, &models, false, true, + vec![pointer_button(point, false)]).0.trigger, Some(1)); + assert!(frame(&ctx, &mut positions, &models, false, true, vec![]).0.trigger.is_none()); + } + } + fn frame( context: &egui::Context, positions: &mut [[f32; 2]; 9], @@ -689,8 +973,10 @@ mod tests { "fixture-board", positions, models, + &mut Selection::default(), editing, allowed, + true, false, ); bounds = Rect::from_min_size(origin, egui::vec2(720.0, HEIGHT)); @@ -751,6 +1037,7 @@ mod tests { result.trigger, expected, "editing={editing} allowed={allowed} enabled={enabled}" ); + assert_eq!(result.blocked_trigger, (!editing && !allowed && enabled).then_some(1)); assert!(!result.layout_changed); assert_eq!(positions, initial); } diff --git a/desktop/src/ui/layout.rs b/desktop/src/ui/layout.rs index bcdd366e..55e429dd 100644 --- a/desktop/src/ui/layout.rs +++ b/desktop/src/ui/layout.rs @@ -10,6 +10,9 @@ pub enum Panel { PixelDiagnostics, PixelCorrections, Keys, + Game, + GameSettings, + Melodies, EffectControls, FrontPanel, Hardware, @@ -18,11 +21,14 @@ pub enum Panel { Events, } impl Panel { - pub const ALL: [Self; 10] = [ + pub const ALL: [Self; 13] = [ Self::Table, Self::PixelDiagnostics, Self::PixelCorrections, Self::Keys, + Self::Game, + Self::GameSettings, + Self::Melodies, Self::EffectControls, Self::FrontPanel, Self::Hardware, @@ -36,6 +42,9 @@ impl Panel { Self::PixelDiagnostics => "Pixel diagnostics", Self::PixelCorrections => "Pixel corrections", Self::Keys => "Keys", + Self::Game => "Game", + Self::GameSettings => "Game settings", + Self::Melodies => "Melodies", Self::EffectControls => "Effect controls", Self::FrontPanel => "Front panel", Self::Hardware => "Hardware monitor", @@ -51,6 +60,9 @@ impl Panel { Self::PixelDiagnostics => MAGNIFYING_GLASS, Self::PixelCorrections => SLIDERS_HORIZONTAL, Self::Keys => HAND_TAP, + Self::Game => PLAY, + Self::GameSettings => GEAR, + Self::Melodies => MUSIC_NOTE, Self::EffectControls => SLIDERS_HORIZONTAL, Self::FrontPanel => GAUGE, Self::Hardware => CIRCUITRY, @@ -77,6 +89,8 @@ impl Panel { Self::PixelDiagnostics => c.has("pixel-diagnostic"), Self::PixelCorrections => c.has("pixel-corrections"), Self::Keys => c.role == "first-wins" && c.has("inputs") && c.has("outputs"), + Self::Game | Self::GameSettings => c.role == "first-wins" && c.has("inputs") && c.has("outputs"), + Self::Melodies => c.has("buzzer"), Self::EffectControls => c.has("leds") || c.modes().next().is_some(), Self::FrontPanel => { c.has("display") || c.has("text") || c.has("buzzer") || c.has("lcd") @@ -94,10 +108,10 @@ pub fn create_initial_layout() -> DockState { let [center, right] = dock.main_surface_mut().split_right( NodeIndex::root(), 0.72, - vec![Panel::EffectControls, Panel::PixelDiagnostics, Panel::PixelCorrections, Panel::Network], + vec![Panel::Game, Panel::GameSettings, Panel::EffectControls, Panel::PixelDiagnostics, Panel::PixelCorrections, Panel::Network], ); dock.main_surface_mut() - .split_below(right, 0.48, vec![Panel::FrontPanel, Panel::Hardware]); + .split_below(right, 0.48, vec![Panel::FrontPanel, Panel::Melodies, Panel::Hardware]); dock.main_surface_mut() .split_below(center, 0.80, vec![Panel::Events, Panel::Streaming]); sanitize_dock_rects(&mut dock); @@ -181,9 +195,9 @@ pub fn restore_canonical(dock: &mut DockState, panel: Panel) { } } let siblings: &[Panel] = match panel { - Panel::FrontPanel | Panel::Hardware => &[Panel::FrontPanel, Panel::Hardware], + Panel::FrontPanel | Panel::Melodies | Panel::Hardware => &[Panel::FrontPanel, Panel::Melodies, Panel::Hardware], Panel::Events | Panel::Streaming => &[Panel::Events, Panel::Streaming], - Panel::Network | Panel::EffectControls | Panel::PixelDiagnostics | Panel::PixelCorrections => &[Panel::EffectControls, Panel::PixelDiagnostics, Panel::PixelCorrections, Panel::Network], + Panel::Game | Panel::GameSettings | Panel::Network | Panel::EffectControls | Panel::PixelDiagnostics | Panel::PixelCorrections => &[Panel::Game, Panel::GameSettings, Panel::EffectControls, Panel::PixelDiagnostics, Panel::PixelCorrections, Panel::Network], _ => &[], }; if let Some(path) = siblings.iter().find_map(|p| dock.find_tab(p)) { @@ -203,11 +217,11 @@ pub fn restore_canonical(dock: &mut DockState, panel: Panel) { dock.main_surface_mut() .split_left(path.node, 0.72, vec![panel]); } - Panel::EffectControls | Panel::PixelDiagnostics | Panel::PixelCorrections => { + Panel::Game | Panel::GameSettings | Panel::EffectControls | Panel::PixelDiagnostics | Panel::PixelCorrections => { dock.main_surface_mut() .split_right(path.node, 0.72, vec![panel]); } - Panel::FrontPanel | Panel::Hardware => { + Panel::FrontPanel | Panel::Melodies | Panel::Hardware => { let anchor = dock.find_tab(&Panel::EffectControls).unwrap_or(path); if anchor.surface == egui_dock::SurfaceIndex::main() { dock.main_surface_mut() diff --git a/desktop/src/ui/menu.rs b/desktop/src/ui/menu.rs index f6e6d3f4..4d23e1b9 100644 --- a/desktop/src/ui/menu.rs +++ b/desktop/src/ui/menu.rs @@ -3,6 +3,12 @@ //! Button::shortcut_text idiom; no popup height/font/spacing override is added. use eframe::egui; +/// Imported Pealayer icons::submenu: the same Phosphor caret as other controls. +pub fn submenu(ui: &mut egui::Ui, label: String, contents: impl FnOnce(&mut egui::Ui)) { + let button = egui::Button::new(label).right_text(super::icons::CARET_RIGHT); + let _ = egui::containers::menu::SubMenuButton::from_button(button).ui(ui, contents); +} + /// Switch sibling menus on hover while the menubar is active, matching the /// interaction of native Windows menu bars. egui's root menu buttons otherwise /// only toggle on click. diff --git a/desktop/src/ui/mod.rs b/desktop/src/ui/mod.rs index 66e1eddf..75a832b9 100644 --- a/desktop/src/ui/mod.rs +++ b/desktop/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod cell_search; pub mod cell_table; pub mod color_picker; pub mod connection_controls; +pub mod host_wifi; pub mod control_density; pub mod dialog; pub mod digits; diff --git a/desktop/src/ui/panel_details.rs b/desktop/src/ui/panel_details.rs index a0e063c5..8948721e 100644 --- a/desktop/src/ui/panel_details.rs +++ b/desktop/src/ui/panel_details.rs @@ -158,7 +158,8 @@ pub fn physical_groups(ui: &mut egui::Ui, value: &mut crate::pixel_groups::Layou } /// Edits only a local revision-bound draft. No device write occurs on paint. -pub fn correction_rules(ui: &mut egui::Ui, value: &mut pixel_corrections::Configuration, rule_toggle: bool) -> bool { +pub fn correction_rules(ui: &mut egui::Ui, value: &mut pixel_corrections::Configuration, rule_toggle: bool, + preview:Option<&pixel_corrections::Configuration>,cursor:Option) -> bool { let mut dirty = false; ui.scope(|ui| { // Narrow dock projections can inherit Extend from the surrounding tab. @@ -180,6 +181,7 @@ pub fn correction_rules(ui: &mut egui::Ui, value: &mut pixel_corrections::Config let mut remove = None; let mut reorder = None; let count = value.rules.len(); + let cursor_slots:Vec<_>=(0..=count).map(|index|cursor.map(|cursor|value.wire_for_cursor(cursor,index))).collect(); for (index, rule) in value.rules.iter_mut().enumerate() { ui.add_space(8.0); ui.separator(); @@ -224,18 +226,24 @@ pub fn correction_rules(ui: &mut egui::Ui, value: &mut pixel_corrections::Config if square_action(ui,icons::MINUS,"Previous pixel",rule.pixel>0 && value.pixels>0).clicked() { dirty|=rule.step_pixel(-1,value.pixels); } - let mut pixel = rule.pixel.saturating_add(1); + let mut pixel = rule.human_pixel(); if ui .add(egui::DragValue::new(&mut pixel).range(1..=value.pixels.max(1))) .changed() { - rule.pixel = pixel - 1; - dirty = true; + dirty |= rule.set_human_pixel(pixel, value.pixels); } if square_action(ui,icons::PLUS,"Next pixel",value.pixels>0 && rule.pixelformat!("Output {} after preceding rules",wire+1),Err(error)=>error.clone()}); + } if rule.kind == "shift" { ui.horizontal_wrapped(|ui| { ui.label(super::i18n::text("Offset")); @@ -276,16 +284,25 @@ pub fn correction_rules(ui: &mut egui::Ui, value: &mut pixel_corrections::Config }); dirty = true; } + if let Some(slot)=&cursor_slots[count] { + let response=ui.add_enabled_ui(value.rules.len()<32 && slot.is_ok(),|ui| + dialog::action_button(ui,icons::TARGET,"Add at diagnostic cursor")).inner; + if response.clicked() && let Ok(wire)=slot { + value.rules.push(pixel_corrections::Rule{kind:"black".into(),pixel:*wire,offset:0,enabled:true});dirty=true; + } + response.on_hover_text(match slot {Ok(wire)=>format!("Raw {} → output {}",cursor.unwrap().raw+1,wire+1),Err(error)=>error.clone()}); + } }); ui.add_space(8.0); - dialog::section(ui, icons::LIST_CHECKS, "Applied wire mapping", |ui| { + dialog::section(ui, icons::LIST_CHECKS, if preview.is_some() {"Live wire preview"} else {"Applied wire mapping"}, |ui| { detail( ui, icons::CHECK, "Saved revision", &value.revision.to_string(), ); - if value.sources.is_empty() { + let sources=preview.map(|actual|actual.active_sources.as_slice()).unwrap_or(&value.sources); + if sources.is_empty() { ui.weak("Mapping has not been returned by the board."); } else { ui.weak("Wire pixel ← raw source"); @@ -294,9 +311,9 @@ pub fn correction_rules(ui: &mut egui::Ui, value: &mut pixel_corrections::Config .id_salt("wire-sources") .max_height(220.0) .auto_shrink([false, true]) - .show_rows(ui, row_height, value.sources.len(), |ui, rows| { + .show_rows(ui, row_height, sources.len(), |ui, rows| { for wire in rows { - let source = value.sources[wire]; + let source = sources[wire]; ui.monospace(format!( "{:>3} ← {}", wire + 1, @@ -616,6 +633,7 @@ mod tests { }], pixels: 480, sources: (0..480).collect(), + ..Default::default() }; let before = serde_json::to_value(&value).unwrap(); for corrections in [false, true] { @@ -632,7 +650,7 @@ mod tests { }, |ui| { if corrections { - dirty = correction_rules(ui, &mut value, true); + dirty = correction_rules(ui, &mut value, true,None,None); } else { intent = network(ui, &status); } @@ -665,12 +683,12 @@ mod tests { for supported in [false,true] { let ctx=egui::Context::default(); let mut value=pixel_corrections::Configuration {revision:9,enabled:true,pixels:480,sources:vec![0,-1,2], - rules:vec![pixel_corrections::Rule{kind:"shift".into(),pixel:7,offset:-2,enabled:false}]}; + rules:vec![pixel_corrections::Rule{kind:"shift".into(),pixel:7,offset:-2,enabled:false}],..Default::default()}; let render=|value:&mut pixel_corrections::Configuration,events| { let mut changed=false; let mut out=ctx.run_ui(egui::RawInput{events, screen_rect:Some(egui::Rect::from_min_size(egui::Pos2::ZERO,egui::vec2(300.0,900.0))),..Default::default()},|ui| { - changed=correction_rules(ui,value,supported); + changed=correction_rules(ui,value,supported,None,None); }); let point=out.shapes.iter().find_map(|shape|match &shape.shape { egui::Shape::Text(text) if text.galley.text()=="Rule 1"=>Some(text.pos+egui::vec2(4.0,4.0)),_=>None diff --git a/desktop/src/ui/shell.rs b/desktop/src/ui/shell.rs index 3b3335f6..a50ae87a 100644 --- a/desktop/src/ui/shell.rs +++ b/desktop/src/ui/shell.rs @@ -20,7 +20,9 @@ pub fn launch_command(args: &[String]) -> Command { .filter(|v| !v.starts_with("--")) .cloned() }; - if args.iter().any(|v| v == "--wifi") { + if args.iter().any(|v| v == "--snake") { + Command::Snake + } else if args.iter().any(|v| v == "--wifi") { Command::UseWifi { endpoint: option("--endpoint"), } @@ -470,7 +472,11 @@ impl PakarControllerApp { if path == pixel_corrections::PATH { self.pixel_corrections.pending = None; match serde_json::from_value::(value) { - Ok(value) if value.pixels > 0 && value.pixels <= 480 && value.rules.len() <= 32 => { + Ok(value) if value.validate().is_ok() => { + if self.live_status().and_then(peer_http::Pin::from_status) != self.pixel_corrections.owner { + self.pixel_corrections.message = "Correction readback belongs to a retired board".into(); + return; + } if self .pixel_corrections .expected @@ -485,6 +491,7 @@ impl PakarControllerApp { } self.pixel_corrections.value = Some(value); self.pixel_corrections.dirty = false; + self.pixel_corrections.live.committed(); self.pixel_corrections.message = "Board configuration read back".into(); } _ => self.pixel_corrections.message = "Invalid correction readback".into(), @@ -754,6 +761,7 @@ impl PakarControllerApp { Command::NotificationTest => self.notification_test(), Command::Connect => self.show_connection = true, Command::About => self.show_about = true, + Command::Snake => ctx.open_url(egui::OpenUrl::new_tab("http://127.0.0.1:18490/snake")), Command::Workspaces => self.show_workspaces = true, Command::CellManager => { if !self.has_table() { @@ -1001,6 +1009,8 @@ impl PakarControllerApp { Panel::PixelDiagnostics => &["pixel-diagnostic"], Panel::PixelCorrections => &["pixel-corrections"], Panel::Keys => &["inputs", "outputs"], + Panel::Game | Panel::GameSettings => &["inputs", "outputs"], + Panel::Melodies => &["buzzer"], Panel::EffectControls => &["table", "leds", "inputs", "outputs"], Panel::FrontPanel => &["display", "text", "buzzer", "lcd"], Panel::Hardware => &["telemetry", "inputs", "outputs"], @@ -1152,6 +1162,12 @@ impl PakarControllerApp { .map(|a| a.name.clone()) .unwrap_or_else(|| "PakarController".into()); if let Some(icon) = crate::branding::sync_peer(ctx, self.presentation_context()) { + #[cfg(windows)] + if let Some(desktop) = &self.windows_desktop { + if let Err(error) = desktop.set_icon(&icon) { + self.record_event("Windows application artwork", &error); + } + } if let Some(tray) = &self.tray { if let Err(error) = tray.set_application(&app_name, &icon) { self.record_event("Application artwork", &error); @@ -1201,6 +1217,8 @@ impl PakarControllerApp { i.viewport().occluded.unwrap_or(false), ) }); + self.service_pixel_correction_preview(!minimized && !occluded && !self.exiting + && self.usb_ui_visible && ctx.input(|i|i.focused)); let ui_demand = crate::usb_demand::visible_demand( self.usb_ui_demand, self.usb_ui_visible, @@ -1732,6 +1750,7 @@ impl PakarControllerApp { method: self.transport, enabled: self.connection_enabled, live: self.command_route().is_some(), + peer_known: self.presentation_context().is_some(), endpoint: &self.base, port: &self.serial_port, identity_pending: self.identity_pending, @@ -1947,7 +1966,7 @@ impl PakarControllerApp { crate::usb_demand::PIXELS } Panel::Keys | Panel::PixelDiagnostics => crate::usb_demand::PIXELS, - Panel::FrontPanel => crate::usb_demand::PANEL | crate::usb_demand::PERIPHERAL, + Panel::FrontPanel | Panel::Melodies => crate::usb_demand::PANEL | crate::usb_demand::PERIPHERAL, Panel::Events => crate::usb_demand::PANEL, _ => 0, }; @@ -1986,6 +2005,9 @@ impl PakarControllerApp { }); } Panel::Keys => self.draw_hardware(ui, true), + Panel::Game => self.draw_game(ui), + Panel::GameSettings => self.draw_game_settings(ui), + Panel::Melodies => self.draw_melodies(ui), Panel::EffectControls => self.draw_effects(ui), Panel::FrontPanel => self.draw_front_panel(ui), Panel::Hardware => self.draw_hardware(ui, false), @@ -2168,6 +2190,21 @@ impl PakarControllerApp { Err("USB API peer retired; result not applied".into()) } else {result}; match purpose { + pixel_groups::Purpose::Minigame(request) => { + match result.and_then(|value|request.verify(value)) { + Ok(observed) => { + if let Some(status)=self.serial_status.as_mut() { + if observed.command_clock_ms>=status.command_clock_ms { + status.minigame=observed.minigame; + status.command_clock_ms=observed.command_clock_ms; + status.winner=observed.winner; + } + } + self.message="Game command ACK and same-board readback verified over USB".into(); + }, + Err(error)=>self.message=error, + } + }, pixel_groups::Purpose::Frame{revision,layout_revision,at}=> { if let Ok(value)=result && let Some(status)=self.live_status() { let peer=peer_http::Pin::from_status(status); @@ -2255,11 +2292,13 @@ impl PakarControllerApp { fn draw_pixel_corrections(&mut self, ui: &mut egui::Ui) { let pin = self.live_status().and_then(peer_http::Pin::from_status); if pin != self.pixel_corrections.owner { + let preview_enabled = self.config.pixel_correction_live_preview; self.pixel_corrections = pixel_corrections::Editor { tab: true, owner: pin.clone(), ..Default::default() }; + self.pixel_corrections.live.enabled = preview_enabled; } if self.transport != Transport::WiFi || pin.is_none() { ui.weak("Correction configuration requires the board Wi-Fi connection."); @@ -2273,7 +2312,8 @@ impl PakarControllerApp { self.pixel_corrections.pending = None; self.pixel_corrections.message="No correction receipt arrived. Read from board before retrying; no write replayed.".into(); } - let busy = self.pixel_corrections.pending.is_some(); + self.pixel_corrections.live.painted_at = Some(Instant::now()); + let busy = self.pixel_corrections.pending.is_some() || self.pixel_corrections.live.pending.is_some(); // Match Flutter: load once when this peer's editor opens. A failed or // uncertain read keeps its message and requires an explicit retry. let mut read = self.pixel_corrections.value.is_none() @@ -2282,6 +2322,31 @@ impl PakarControllerApp { let mut save = false; let rule_toggle=self.live_status().and_then(|s|s.capabilities.as_ref()) .is_some_and(|c|c.has("pixel-correction-rule-toggle")); + let preview_supported=self.live_status().and_then(|s|s.capabilities.as_ref()) + .is_some_and(|c|c.has(pixel_corrections::PREVIEW_FEATURE)); + let cursor=self.live_status().and_then(|status| { + let observed=pixel_diagnostic::Observation::read(status)?; + (observed.active && !observed.off && !observed.probe.group && self.pixel_diagnostic.pending.is_none()) + .then_some(pixel_corrections::DiagnosticCursor{raw:observed.probe.index,revision:status.pixel_corrections_revision?}) + }); + if preview_supported { + let mut enabled=self.pixel_corrections.live.enabled; + if ui.checkbox(&mut enabled,ui::i18n::text("Live preview")).changed() { + self.pixel_corrections.live.toggle(enabled); + self.config.pixel_correction_live_preview=enabled; + // Explicit preference only: do not commit open Preferences + // drafts, a preview lease, or board configuration here. + if let Err(error)=config::AppConfig::load().and_then(|mut saved|{ + saved.pixel_correction_live_preview=enabled;saved.save() + }) {self.pixel_corrections.message=format!("Preview preference not saved: {error}");} + } + if self.pixel_corrections.live.observed.is_some() { + ui.weak("Temporary preview · Save makes it persistent"); + } + if !self.pixel_corrections.live.error.is_empty() { + ui.colored_label(ui.visuals().warn_fg_color,&self.pixel_corrections.live.error); + } + } let changed = self .live_status() .and_then(|s| s.pixel_corrections_revision) @@ -2340,11 +2405,19 @@ impl PakarControllerApp { }); ui.add_space(8.0); if let Some(value) = self.pixel_corrections.value.as_mut() { - self.pixel_corrections.dirty |= super::panel_details::correction_rules(ui, value, rule_toggle); + let edited=super::panel_details::correction_rules(ui, value, rule_toggle,self.pixel_corrections.live.observed.as_ref(),cursor); + self.pixel_corrections.dirty |= edited; + if edited && preview_supported {self.pixel_corrections.live.edited(Instant::now());} } }); ui.label(&self.pixel_corrections.message); if read { + self.pixel_corrections.live.stop(); + if self.pixel_corrections.live.save_lease().is_some() { + self.pixel_corrections.reload_after_preview=true; + self.pixel_corrections.message="Stopping temporary preview before reloading…".into(); + return; + } self.pixel_corrections.pending = Some(std::time::Instant::now()); self.pixel_corrections.expected = None; self.pixel_corrections.message = "Reading board configuration…".into(); @@ -2354,9 +2427,9 @@ impl PakarControllerApp { )); } if save && let Some(value) = self.pixel_corrections.value.as_ref() { - match serde_json::to_string( - &serde_json::json!({"enabled":value.enabled,"revision":value.revision,"rules":value.rules}), - ) { + let mut saved=value.write_value(); + if let Some(lease)=self.pixel_corrections.live.save_lease() {saved["lease_id"]=lease.into();} + match serde_json::to_string(&saved) { Ok(json) => { self.pixel_corrections.expected = Some(value.clone()); self.pixel_corrections.pending = Some(std::time::Instant::now()); @@ -2371,6 +2444,56 @@ impl PakarControllerApp { } } } + fn service_pixel_correction_preview(&mut self, foreground:bool) { + let now=Instant::now(); + let status=self.live_status().cloned(); + let pin=status.as_ref().and_then(peer_http::Pin::from_status); + if pin.is_some() && pin!=self.pixel_corrections.owner { + let enabled=self.config.pixel_correction_live_preview; + self.pixel_corrections.live=pixel_corrections::LivePreview::default(); + self.pixel_corrections.live.enabled=enabled; + return; // Never send old-owner cleanup to a newly connected board. + } + let allowed=foreground && self.http_writable() && pin.is_some() + && status.as_ref().and_then(|s|s.capabilities.as_ref()).is_some_and(|c|c.has(pixel_corrections::PREVIEW_FEATURE)) + && self.pixel_corrections.live.painted_at.is_some_and(|at|now.saturating_duration_since(at) = audio::CUES.into_iter().filter(|(cue, _)| snapshot.as_ref().is_some_and(|s| s.audio_cues.iter().any(|v| v == cue))).collect(); + let labels: Vec<_> = cues.iter().map(|(_, name)| (icons::PLAY, *name)).collect(); + super::panel_details::action_row(ui, &labels, |ui| { for (cue, name) in audio::CUES { if !snapshot .as_ref() @@ -3197,116 +3330,16 @@ impl PakarControllerApp { } }); } - pub(super) fn draw_hardware(&mut self, ui: &mut egui::Ui, game_only: bool) { - let Some(s) = self.live_status().cloned() else { - ui.label(crate::ui::i18n::text("Hardware state unavailable")); - return; - }; - let avr_acknowledged = ["display", "text", "buzzer", "inputs", "outputs", "settings"] - .iter() - .any(|feature| self.has_feature(feature)); - if game_only - && s.capabilities - .as_ref() - .is_some_and(|c| c.role == "first-wins") - { - let mask = s.key_game["enabled_mask"].as_u64().unwrap_or(511) as u16; - dialog::section(ui, icons::HAND_TAP, "Keys", |ui| { - ui.horizontal_wrapped(|ui| { - ui.strong(if s.winner == 0 { - "Ready for first press".to_owned() - } else { - format!("Winner · Box {}", s.winner) - }); - ui.add_enabled_ui(s.avr_online && !s.programming_latch, |ui| { - if dialog::primary_action_button( - ui, - icons::ARROW_COUNTER_CLOCKWISE, - "Reset to white", - ) - .clicked() - { - // A round control, not a destructive settings operation: send - // immediately through the selected ACK-tracked WS/USB owner. - self.action("/api/game/reset", vec![], OP_RESET, vec![]); - } - }); - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui - .selectable_label( - self.key_layout_edit, - format!("{} Arrange boxes", icons::DOTS_SIX_VERTICAL), - ) - .clicked() - { - self.key_layout_edit = !self.key_layout_edit; - } - if self.key_layout_edit - && ui - .button(format!( - "{} Reset positions", - icons::ARROW_COUNTER_CLOCKWISE - )) - .clicked() - { - self.key_positions = crate::native_workspace::default_key_positions(); - self.save_workspace(); - } - }); - let boxes = std::array::from_fn(|index| { - let key = (index + 1) as u8; - let (color_name, fallback) = if s.winner == key { - ("win_rgb", 0x00ff00) - } else if s.winner != 0 { - ("lose_rgb", 0xff0000) - } else { - ("idle_rgb", 0xffffff) - }; - let rgb = s.key_game[color_name].as_u64().unwrap_or(fallback) as u32; - super::key_boxes::BoxView { - enabled: mask & (1 << index) != 0, - pressed: if key == 9 { - s.input_9 - } else { - s.active_inputs & (1 << index) != 0 - }, - winner: s.winner == key, - round_active: s.winner != 0, - color: egui::Color32::from_rgb( - (rgb >> 16) as u8, - (rgb >> 8) as u8, - rgb as u8, - ), - groups: std::array::from_fn(|group| { - self.table - .pixel_sample - .as_ref() - .filter(|sample| sample.fresh()) - .filter(|sample| !pixel_groups::advertised(&s) || pixel_groups::for_status(&s) - .is_some_and(|layout|sample.frame.group_layout_revision==Some(layout.revision))) - .and_then(|sample| sample.frame.key_group(key, group as u8)) - }), - } - }); - let can_trigger = super::effect_controls::game_press_available(&s); - let result = super::key_boxes::show( - ui, - &s.board_id, - &mut self.key_positions, - &boxes, - self.key_layout_edit, - can_trigger, - self.config.animate_cells, - ); - if result.layout_changed { - self.save_workspace(); - } - if let Some(key) = result.trigger { - self.post("/api/game/press", vec![("key".into(), key.to_string())]); - } + pub(super) fn draw_game_settings(&mut self, ui: &mut egui::Ui) { + let Some(s) = self.live_status().cloned() else { return; }; + let mask = s.key_game["enabled_mask"].as_u64().unwrap_or(511) as u16; + let minigame = crate::minigame::snapshot(&s).is_some(); + let minigame_writable = self.minigame_writable(&s); + for request in crate::minigame::settings_controls(ui, &s, minigame_writable) { + if let Err(error) = self.send_minigame(request) { self.message = error; } + } if self.has_feature("key-settings") { + ui.add_enabled_ui(!minigame || minigame_writable, |ui| { if self.has_feature("game-presentation") && s.key_game["presentation"].is_object() { @@ -3321,7 +3354,7 @@ impl PakarControllerApp { ); } } - ui.collapsing("Game settings", |ui| { + dialog::section(ui, icons::GEAR, "Boxes & presentation", |ui| { if self.has_feature("game-melodies") { for (field, label) in [("winner_cue", "Winner melody"), ("reset_cue", "Reset melody")] { if let Some(current) = s.key_game[field].as_str() { @@ -3436,6 +3469,186 @@ impl PakarControllerApp { }); } }); + }); + } + + } + fn minigame_writable(&self, s: &DeviceStatus) -> bool { + crate::realtime::supported(s, crate::minigame::CONTROL) && match self.command_route() { + Some(Transport::WiFi) => self.http_writable(), + Some(Transport::Serial) => self.pixel_api_reply.is_none() && self.has_feature("usb-api"), + None => false, + } + } + pub(super) fn draw_game(&mut self, ui: &mut egui::Ui) { + let Some(s) = self.live_status().cloned() else { return; }; + for request in crate::minigame::controls(ui, &s, self.minigame_writable(&s)) { + if let Err(error) = self.send_minigame(request) { self.message = error; } + } + ui.horizontal_wrapped(|ui| { + if dialog::action_button(ui, icons::GEAR, "Game settings").clicked() { self.reveal_panels.push(Panel::GameSettings); } + if dialog::action_button(ui, icons::HAND_TAP, "Keys").clicked() { self.reveal_panels.push(Panel::Keys); } + }); + } + fn send_minigame(&mut self, request: crate::minigame::Request) -> Result<(),String> { + match self.command_route() { + Some(Transport::WiFi)=>self.queue_realtime(request.path,request.form,None), + Some(Transport::Serial)=> { + let status=self.live_status().ok_or("Fresh USB game status unavailable")?; + let stamp=self.serial_admission().ok_or("USB game owner unavailable")?.stamp; + let (request,packet)=crate::minigame::UsbRequest::prepare(status,request,stamp)?; + self.begin_pixel_api(pixel_groups::Purpose::Minigame(request),"POST","/api/command",packet) + }, + None=>Err("No selected live game transport".into()), + } + } + pub(super) fn draw_hardware(&mut self, ui: &mut egui::Ui, game_only: bool) { + let Some(s) = self.live_status().cloned() else { + ui.label(crate::ui::i18n::text("Hardware state unavailable")); + return; + }; + let avr_acknowledged = ["display", "text", "buzzer", "inputs", "outputs", "settings"] + .iter() + .any(|feature| self.has_feature(feature)); + if game_only + && s.capabilities + .as_ref() + .is_some_and(|c| c.role == "first-wins") + { + let mask = s.key_game["enabled_mask"].as_u64().unwrap_or(511) as u16; + let minigame = crate::minigame::snapshot(&s).is_some(); + let minigame_writable = crate::realtime::supported(&s,crate::minigame::CONTROL) + && match self.command_route() { + Some(Transport::WiFi)=>self.http_writable(), + Some(Transport::Serial)=>self.pixel_api_reply.is_none() && self.has_feature("usb-api"), + None=>false, + }; + dialog::section(ui, icons::HAND_TAP, "Keys", |ui| { + if !minigame { + ui.horizontal_wrapped(|ui| { + ui.strong(if s.winner == 0 { + "Ready for first press".to_owned() + } else { + format!("Winner · Box {}", s.winner) + }); + ui.add_enabled_ui(s.avr_online && !s.programming_latch, |ui| { + if dialog::primary_action_button( + ui, + icons::ARROW_COUNTER_CLOCKWISE, + "Reset to white", + ) + .clicked() + { + // A round control, not a destructive settings operation: send + // immediately through the selected ACK-tracked WS/USB owner. + self.action("/api/game/reset", vec![], OP_RESET, vec![]); + } + }); + }); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.checkbox(&mut self.key_trigger_on_click, "Trigger on click") + .on_hover_text("Off: clicks only select. Ctrl-click toggles; Shift-click selects a range. Right-click for explicit actions.").changed() { + self.save_workspace(); + } + ui.weak(format!("{} selected", self.key_selection.ids.len())); + if dialog::action_button(ui, icons::PLAY, "Game").clicked() { self.reveal_panels.push(Panel::Game); } + if dialog::action_button(ui, icons::GEAR, "Settings").clicked() { self.reveal_panels.push(Panel::GameSettings); } + if super::key_boxes::arrange_control(ui, self.key_layout_edit).clicked() + { + self.key_layout_edit = !self.key_layout_edit; + } + if self.key_layout_edit + && ui + .button(format!( + "{} Reset positions", + icons::ARROW_COUNTER_CLOCKWISE + )) + .clicked() + { + self.key_positions = crate::native_workspace::default_key_positions(); + self.save_workspace(); + } + }); + if minigame && self.key_trigger_on_click { + crate::minigame::input_controls(ui, &s); + } + let boxes = std::array::from_fn(|index| { + let key = (index + 1) as u8; + let winner = if minigame {s.minigame["winner"].as_u64().unwrap_or(0) as u8} else {s.winner}; + let (color_name, fallback) = if winner == key { + ("win_rgb", 0x00ff00) + } else if winner != 0 { + ("lose_rgb", 0xff0000) + } else { + ("idle_rgb", 0xffffff) + }; + let rgb = s.key_game[color_name].as_u64().unwrap_or(fallback) as u32; + super::key_boxes::BoxView { + enabled: mask & (1 << index) != 0, + pressed: if minigame { + s.minigame["held_mask"].as_u64().unwrap_or(0) & (1 << index) != 0 + } else if key == 9 { + s.input_9 + } else { + s.active_inputs & (1 << index) != 0 + }, + winner: winner == key, + round_active: winner != 0, + color: egui::Color32::from_rgb( + (rgb >> 16) as u8, + (rgb >> 8) as u8, + rgb as u8, + ), + groups: std::array::from_fn(|group| { + self.table + .pixel_sample + .as_ref() + .filter(|sample| sample.fresh()) + .filter(|sample| !pixel_groups::advertised(&s) || pixel_groups::for_status(&s) + .is_some_and(|layout|sample.frame.group_layout_revision==Some(layout.revision))) + .and_then(|sample| sample.frame.key_group(key, group as u8)) + }), + } + }); + let can_trigger = if minigame {minigame_writable && crate::minigame::ready(&s)} + else {super::effect_controls::game_press_available(&s)}; + let result = super::key_boxes::show( + ui, + &s.board_id, + &mut self.key_positions, + &boxes, + &mut self.key_selection, + self.key_layout_edit, + can_trigger, + self.key_trigger_on_click, + self.config.animate_cells, + ); + if let Some(editing) = result.editing { self.key_layout_edit = editing; } + if let Some(panel) = result.panel { + if panel.available(self.presentation_context()) { self.reveal_panels.push(panel); } + } + if result.layout_changed || result.selection_changed { + self.save_workspace(); + } + if let Some(key) = result.trigger { + if minigame { + let request=crate::minigame::input(key,&crate::minigame::input_action(ui,&s)); + if let Err(error)=self.send_minigame(request) {self.message=error;} + } else { + // Use the same immediate, ACK-tracked USB/WS owner as + // other hardware actions, including serial timing/readback. + self.action("/api/game/press", vec![("key".into(), key.to_string())], 0x68, vec![key]); + } + } + if let Some(key) = result.blocked_trigger { + let reason = if !s.avr_online { "AVR is offline" } + else if s.programming_latch { "AVR programming is active" } + else if s.key_mask_applied != Some(true) { "Enabled boxes have not been acknowledged by the AVR" } + else if minigame { "The game is not ready for input" } + else { "This board has not enabled remote button presses" }; + self.notify("Box not triggered", &format!("Box {key}: {reason}."), messaging::Severity::Warning); } }); ui.add_space(10.0); @@ -3964,6 +4177,9 @@ impl PakarControllerApp { } else if self.connection_enabled { ui.weak("Disconnect before editing the endpoint or credentials."); } + if method == Transport::WiFi { + ui::host_wifi::draw(ui, !self.identity_pending); + } ui.add_space(12.0); dialog::action_row(ui, false, |ui| { let endpoint_present = match method { diff --git a/desktop/src/ui/status_bar.rs b/desktop/src/ui/status_bar.rs index ba180e76..f6a944d3 100644 --- a/desktop/src/ui/status_bar.rs +++ b/desktop/src/ui/status_bar.rs @@ -232,7 +232,7 @@ impl StatusBar<'_> { _ => true, } } fn connection_label(&self) -> &'static str { - if self.connected { "Connected" } else if self.connection_enabled { "Connecting…" } else { "Disconnected" } + super::connection_controls::phase_label(self.connection_enabled, self.connected, self.peer_known) } fn transport(&self) -> &'static str { if self.serial_selected { "USB" } else if self.websocket_live && self.connected { "WebSocket" } else { "HTTP" } @@ -354,6 +354,7 @@ fn paint_widget(ui: &mut egui::Ui, rect: egui::Rect, widget: Widget, status: &St let accent = ui.visuals().selection.bg_fill; let color = match widget { Widget::Connection if status.connected => if ui.visuals().dark_mode { egui::Color32::from_rgb(83, 195, 132) } else { egui::Color32::from_rgb(28, 115, 72) }, + Widget::Connection if status.connection_enabled => ui.visuals().warn_fg_color, Widget::Events if status.error_count > 0 => ui.visuals().error_fg_color, Widget::Telemetry if status.hardware_warning() => ui.visuals().warn_fg_color, Widget::Progress => accent, @@ -634,7 +635,7 @@ mod tests { assert_eq!(status.transport(), "HTTP"); assert_eq!(status.rgb_color(), Some(egui::Color32::from_rgb(24,105,61))); status.connected = false; - assert_eq!(status.connection_label(), "Connecting…"); + assert_eq!(status.connection_label(), "Reconnecting…"); assert_eq!(status.rgb_color(), None, "Cached emitted RGB is not current output after disconnect"); status.connection_enabled = false; assert_eq!(status.connection_label(), "Disconnected"); diff --git a/docs/API.md b/docs/API.md index e3d989d5..381e3eb3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,9 +1,19 @@ # HTTP and streaming API +Host mini-apps use [host-owned presentation](HOST-PRESENTATION.md) for logical or +physical group colors/effects, durable layers, expiring overlays and atomic +frame fallback through the same HTTP, WebSocket, MQTT and USB command contract. + ESP32-S3 also exposes the same living command/state contract through a selected Bluetooth LE control transport. See [Bluetooth LE](BLUETOOTH.md) for its GATT attributes, exact advertised routes, ownership rules and explicit binary limits. +Optional ESP32-S3 [native SSH, SCP/SFTP and passive FTP](SSH.md) reuse the device shell +and public file service. Build flags include them; the persistent listener stays +disabled until explicitly enabled. `/api/ssh` reports configuration, actual +listener state, host fingerprint and supported transfer directions. `/api/ftp` +configures the separately built, disabled-by-default plaintext service. + Client self-updates, enrollment, remote operational telemetry and bounded client management use a separate configurable unversioned service. See [Client management](CLIENT-MANAGEMENT.md) for its routes, controls and live @@ -20,6 +30,9 @@ from `/api/auth/info` and do not ask for credentials when it is false. Enable **Require authentication** in Device access to protect HTTP routes and `/ws` with browser sessions or explicit Basic credentials. `/`, `/description.xml` and `/api/auth/info` are public reads; `/login` accepts a form POST. +Discovery clients can also GET/HEAD `/board-icon.png` without signing in; +`/description.xml` advertises the matching table/key PNG and presentation URL. +See [UPnP discovery](UPNP.md) for metadata, interface behavior and verification. Local HTTP and Telnet are not encrypted; never expose them directly to the Internet. The recovery AP Wi-Fi password is independent of the device login. See `ACCESS.md` for persistence, CLI/TUI, native editor and physical USB recovery. @@ -284,6 +297,7 @@ mirroring is not muted by this physical-board preference. | POST | `/api/snake/direction` | form `value=up|right|down|left`; opposite turns are rejected | | POST | `/api/brightness` | form `value=1..255` | | POST | `/api/effect` | form `value=none|rainbow|fire|cell-chase`; chase visits logical80 down to1,750ms each, once, on white | +| POST | `/api/led/presentation` | partial `settings` JSON, e.g. `{"fire_rotation":180}`; table rotation accepts0,90,180,270 degrees, saved per board and returned in `state_presentation` | | POST | `/api/scene/save` | explicitly flush the current semantic scene;200 `saved:true` confirms verified commit,503 requires inspection of status, never an automatic replay | | POST | `/api/buzzer` | form `frequency=20..12000`, `duration_ms=1..5000` | | POST | `/api/audio` | form `cue` from peer-acknowledged `audio_cues`, including `attention` and donor welcome variations; 202 means queued, not playback completion | @@ -304,6 +318,17 @@ mirroring is not muted by this physical-board preference. | GET, DELETE | `/api/avr/contracts/result` | inspect or explicitly consume the correlated terminal receipt; never replay an uncertain reset/write | | GET, POST | `/update` | authenticated async ESP self-OTA form/upload; optional file `MD5` preceding the firmware file | +Fire is an explicit global animation in both static-grouped and effects output +modes. Selecting `fire` again restarts from a cold/black frame. On table builds +its two-dimensional heat field follows the staggered logical layout, then the +saved logical-to-physical mapping, custom group lengths and pixel corrections. +`fire_rotation` rotates the flame direction (0° up,90° right,180° down,270° left), +not cell identities. This preference lives in `/presentation.json`; existing +palettes default to0°. Changing direction restarts Fire only if it is active; +reading status never restarts it. Group colors/states and correction tables are +not rewritten. The same `state-presentation` command/settings object is available +through the existing WebSocket, MQTT and encapsulated USB API command paths. + UDP port 7890 accepts bounded `PKAR` chunks **without authentication**; use only on a trusted LAN. The authenticated WebSocket is preferred. Each datagram contains the four @@ -450,12 +475,14 @@ the API; blank omitted password/token fields preserve the stored value. Cloud `display` also accepts the same text scheduling fields above or `{"command":"display","release":true}`. An omitted repeat means one-shot. -The password-authenticated Telnet shell on TCP 23 exposes `status`, `mode`, +The Telnet shell (default TCP 23) exposes `status`, `mode`, `cell`, `cells`, `brightness`, `effect`, `display`, `audio`, and `reset`. It uses the -same validation and state transitions as cloud JSON commands. The password is -the HTTP development password; the service is advertised through mDNS as -`_telnet._tcp`. USB serial remains a framed machine interface rather than a -text shell, so Telnet cannot interfere with the simultaneous desktop link. +same validation and state transitions as cloud JSON commands. Authentication +follows the saved access policy; when enabled, the shell requires its password. +The service is advertised through mDNS as `_telnet._tcp`. USB provides the same +text shell encapsulated in a boot-bound COBS channel, alongside the normal +machine protocol. It never switches the AVR UART into raw text mode. +See [terminal commands and TUI](TERMINAL-UI.md) and `tools/device-shell.ps1`. Telnet supports `display stop` and `display text `. Atomic selection example: `cells 1-80 red fade`; melody example: `audio welcome` @@ -634,7 +661,7 @@ application relay, not NAT, a Wi-Fi extender or a completed mesh. `POST /api/peers/request` accepts JSON: `request_id` (32 lowercase hex digits), `hostname`, destination `board_id`, `boot_id`, `method`, `path`, and optional -`packet`. Only approved read paths and `POST /api/command` are forwarded. For +`packet`, and an optional private IPv4 `address` hint. Only approved read paths and `POST /api/command` are forwarded. For public `GET /api/peer/info` only, boot_id may be empty to learn the current boot of an explicitly identified board. Other calls require that observed boot. Commands carry the original JSON packet string with the same request ID, @@ -647,14 +674,137 @@ clients receive `peer-relay-result` completion notifications. USB maintenance API envelopes can use these same request/result routes. Large downstream JSON may exceed the existing USB API response limit; no binary USB relay is claimed. -One background job and one hop are allowed. Unexpired IDs cannot be evicted; -retired results are never resubmitted automatically. DNS resolves a configured -hostname, the resulting private LAN IP is pinned, and destination board/boot -headers and identity are checked. No credentials are forwarded; authenticated +One background job and one hop are allowed. One lazily allocated worker is +reused; eight receipt fingerprints retain SHA256, not full request bodies. +Unexpired IDs cannot be evicted; retired results are never resubmitted +automatically. `.local` uses bounded mDNS directly (900 ms), not router DNS; +unicast resolution has the same caller deadline and safe late-callback storage. +A destination's identity-verified address is reused until connect failure, +then bounded hostname recovery is attempted. Host headers retain the configured +hostname. An operator-supplied address is only a dial hint: the downstream +identity must be verified before any command. After a cached connection fails, +a different supplied hint can recover a DHCP change even when mDNS is unavailable; +it receives a fresh identity probe before the requested read or command. +Cached same-boot GETs need one +HTTP read, not a redundant identity preflight; POST commands still preflight. +Identity/auth/HTTP errors never redirect a command and POST is never replayed. +No credentials are forwarded; authenticated destinations reject access normally. This LAN HTTP identity check does not provide cryptographic peer authentication. Raw files, firmware writes, general URLs and recursive relay endpoints are not admitted. -Pending: persistent trust/routes and discovery, continuous peer events, +`GET /api/peers` includes saved/observed routes, verification age/boot, current +internal free/largest-block memory and the separately labeled historical minimum. +Old low-water marks are not attributed to a cause or presented as current failure. +Explicit `POST /api/peers/routes` JSON `{ "action": "save", "board_id": "...", +"hostname": "keycontroller.local" }` saves only a route verified this boot. +`action: "remove"` removes that saved route and its RAM hint. Four routes fit in +`/peer-routes.json`; stage/readback/rename publishes changes, and identical saves +don't write flash. No observed/discovered address is persisted automatically. +Saved addresses are reverified after reboot; boot IDs are never persisted as +authority. These routes also work through the fenced USB maintenance API. +No settings/scene/correction values are rewritten by a route change. + +Result receipts include `elapsed_ms`, `resolution_ms`, `http_ms`, `route_source`, +`address`, and `command_dispatched`. These measure the relay job, not optical +output or the separate observer/admission/polling latency. The read-only probe +`node tools/check-peer-relay.mjs --origin http://ledcontroller.local +--target-hostname keycontroller.local --target-board TARGET_BOARD_ID +--target-address 192.168.1.201 --samples 3` records both timing scopes without +LED commands or route persistence. Use the currently observed target address, +not the example address blindly. + +Pending: automatic discovery, continuous peer events and a fully selectable +relayed device/transport in Flutter/Rust/Web. The endpoint deliberately keeps +`discovery` and `event_subscription` false until implemented. Also pending: BLE-central/ESP-NOW/USB-host adapters, remote TLS peers, and resumable verified file/firmware transfer. Existing direct OTA remains the update owner. + +## Clock and network observation tools + +ESP32 builds with networking advertise `network-tools`. There is one owner for +these settings and observations across HTTP, local WebSocket, MQTT, remote +WebSocket and the boot-fenced USB API. Configuration is persisted separately in +`/network-tools.json`; runtime observations are never written to flash or +accepted as client-provided public-IP/DNS/ping results. + +| Operation | HTTP / USB API | Shared command | +| --- | --- | --- | +| Configuration + actual UTC, effective NTP servers, public IP, last probe | `GET /api/network/tools` | `network-tools` | +| Save a typed partial configuration | `POST /api/network/tools` | `configure-network-tools`, `settings: {...}` | +| Start a bounded diagnostic | `POST /api/network/probe` | `network-probe`, `kind`, optional `target` | +| Read actual UTC | `GET /api/time` | clock in `network-tools` | +| Set UTC explicitly | `POST /api/time`, integer `utc_epoch` | `set-clock`, integer `utc_epoch` | +| Request asynchronous SSID scan | `GET /api/wifi/scan` | `wifi-scan` | +| Read scan results without restarting | `GET /api/wifi/scan?results=true` | `wifi-scan-results` | +| Select/connect/save a station profile | `POST /api/network` | `configure-network`, `settings: {...}` | + +HTTP tools configuration is form-encoded `settings=`. USB bodies +are the typed object directly, not the HTTP form wrapper. USB scan result reads +use `body: {"results":true}`. Configuration export/import includes the +`network_tools` section (only configuration, never acquired values). Existing +station/network, cloud and Ethernet export/import behavior is retained. + +Settings: `ntp_enabled` (true), `ntp_dhcp` (true), `ntp_server` +(`pool.ntp.org`), `http_date_url` (`https://example.com/`), +`http_date_automatic` (false), `public_ip_url` (`https://api.ipify.org/`), +`public_ip_automatic` (false), `refresh_seconds` (3600, range 60–86400). +URLs must use HTTPS with a hostname, without credentials, fragments, custom +ports or controls. Lookup services are public, unauthenticated endpoints; do not +put secrets in their URLs. Automatic third-party HTTP lookups are opt-in. + +SNTP is configured before interface DHCP starts, so DHCP option 42 can supply +servers without an address-change callback replacing them with hard-coded +ones. `ntp_server` remains the fallback; disable `ntp_dhcp` to force it. Changing +the DHCP policy affects the next DHCP lease; saving does not disconnect Wi-Fi. +`effective_ntp_servers` reports SDK observations rather than inferring that a +DHCP server was used. NTP is not authenticated (no NTS). UTC includes its source, +uptime and last-set uptime. Local clock setting also permits offline bootstrap. + +Probe kinds: `dns` (IPv4 A; hostname or literal), `ping` (three ICMP requests), +`http-date` and `public-ip` (use their saved URLs). One job is admitted at a time, +with a maintenance lease; a background worker performs I/O, never the game loop. +DNS is bounded to 900 ms. ICMP uses 700 ms per reply and a 200 ms interval. +HTTPS uses a pinned resolved public IPv4 address, the original TLS hostname/SNI, +the SDK CA bundle, an absolute read deadline, no redirects or insecure fallback, +and a `PakarController/` user agent. Public-IP bodies are limited to 64 +bytes with a known Content-Length and must contain one valid public IPv4 address. +IPv6 public-IP lookup and traceroute are not implemented or advertised. + +HTTP `Date` must be exact IMF-fixdate with a real date, matching weekday, UTC, +no stale cache Age, and agreement within five minutes of the current clock. +HTTPS requires an initialized clock for certificate verification: it does not +bypass TLS to bootstrap an unset clock. It independently checks NTP time; with +NTP disabled it can maintain a previously initialized clock. Failed observations +retain the last successful public-IP sample with its age, not a fabricated +current value. A failed ping does not prove the target is offline (ICMP can be +filtered). DNS and ping do not prove Internet availability either. + +Completion emits peer-bound `network-observation` events; settings and SNTP +changes emit `configuration-changed` with scope `network-tools`. Full operational +telemetry contains both `network_tools.settings` and actual `clock`, `observed`, +`job` snapshots. MQTT and remote WebSocket commands use the normal board/boot, +request-ID, UTC expiry and replay guards; diagnostics do not bypass these guards. +They remain disabled on instances where cloud features are disabled. + +Flutter: **⋮ → Advanced → Clock & network tools**. Wi-Fi, board outbound +connections and the app bridge are separate Advanced destinations. The tools +page preserves unsaved edits, verifies saved readback, listens for WebSocket +events, and uses bounded read-only result polling for USB/HTTP-only sessions. +Opening it never starts a probe or applies settings. +Rust's Wi-Fi editor also consumes asynchronous scan results, preserving its +previous list until completion. Result-only reads are scheduled 500 ms apart +with a 15-second deadline; the command worker never sleeps for a scan. + +Telnet/framed serial: `clock`, `dns example.com`, `ping 192.168.4.1`, +`public-ip`, `http-date`. `clock` reads completion results. Typed properties use +the same owner, e.g. `set clock.ntp_server pool.ntp.org`, +`set clock.ntp_dhcp false`, `set network.public_ip_automatic true`, +`set network.observation_seconds 3600`, `set clock.epoch 1793190896`. +Existing `network scan` / `network scan-results` and `network.ssid`/password +properties select station networks. No alternate raw UDP command authority is +introduced. `tools/invoke-usb-api.ps1` carries these API requests on the existing +COBS/CRC USB transport without opening Wi-Fi or resetting the device. + +Source/fixture and SDK build evidence is not live NTP/DHCP, Internet, RF scanning, +MQTT or board acceptance; those remain separately verified deployment gates. diff --git a/docs/CLIENT-CONTROL-CANDIDATE.md b/docs/CLIENT-CONTROL-CANDIDATE.md new file mode 100644 index 00000000..cc437bf3 --- /dev/null +++ b/docs/CLIENT-CONTROL-CANDIDATE.md @@ -0,0 +1,87 @@ +# Client controls and host presentation candidate + +Tracking: [issue #26](https://github.com/atomicdeploy/PakarController/issues/26). +This checkpoint prepares updates; it does not record a board or client deployment. + +## Implemented and locally verified + +- Diagnostic pixels and outgoing correction slots are explicitly different address + spaces. Handoff resolves the saved source map, converts UI numbering exactly + once, and requires a choice for ambiguous/missing mappings. No saved correction + index, rule ordering, EEPROM or filesystem data is migrated. +- Flutter and native correction preview is enabled by default, but opening the + page is passive. Editing uses a bounded RAM lease and exact active-map readback. + Save is the durable operation. Web uses the same existing preview controller. +- Flutter correction read/preview/save works through the generic USB API as well + as the correlated WebSocket/HTTP path. **Native correction management remains + Wi-Fi-only**; its panel does not claim USB parity. +- Effects controls follow peer-bound snapshots while open; confirmed readback + updates the visible values. A changed boot retires the editor. Flutter has a + shared sixteen-name palette, HSV controls, optional hex and session recents. +- Flutter pinch owns two-finger gestures without consuming normal cell holds. + Pan stays bounded; release snaps smoothly and new wheel/fit input cancels a + pending snap. Narrow-phone footer values ellipsize and retain full details. +- Removed repeated physical-output narration from cell success messages and + added a source regression guard. Logs/protocol still distinguish acceptance. +- Keycontroller Web appearance mounts in the canonical workspace, not the removed + `.stack` container. Its live dropdown changed twice without reopening in the + isolated browser fixture; this is not hardware acceptance. +- Host presentations share one compositor, mapping, correction pass and output + driver. Cells/groups, bulk updates, persistent saves, temporary overlays and + complete RGB frames use a fenced common command. See + [HOST-PRESENTATION.md](HOST-PRESENTATION.md) and the explicit-run examples. +- Melody/beep/relay commands use typed WebSocket commands with actual AVR handler + acknowledgement. AVR starts the tone or latches outputs before ACK; ESP services + local input before optional outbound housekeeping. No acoustic latency claim. + +egui/eframe stays **0.36.2**. The current dock, Phosphor and theme dependency +family requires that type family; forcing core 0.37 alone is not a coherent +upgrade. This is a compatibility deferral, not a toolchain/MSRV limitation. + +## Evidence + +- Flutter analyzer clean; 84 focused widget/model tests, 5 actual local + WebSocket/HTTP transport tests and 2 USB correction transport tests passed. + Final pointer-retirement repair additionally passed all 7 viewport tests; + the Android candidate below includes that repair. +- Native canonical Check/Release passed; 18 correction and 10 realtime tests + passed. The latter includes the AVR fast-command test. +- Canonical LED pipeline, audio, USB API/receipt and host-presentation tests + passed. Full captured USB status with host summary fits the existing budget. +- Nine affected Web suites passed, including actual extracted renderer behavior, + preview lifecycle, command recovery, workspace/peer guards and gzip round-trip. +- Source versions agree at 0.2.8. Donor comparisons retain explicit omissions; + neither a current hash nor a test anchor closes broader parity work. + +## Built artifacts (local candidates) + +| Artifact | SHA-256 | +| --- | --- | +| `build/esp32s3-buffered/esp32s3.ino.bin` | `9920bc9de5d07f8cd8dec169bd707d15eecdc8ced5f9e4fe73ff2447e7c3c755` | +| `build/avr-buffered/avr.ino.with_bootloader.bin` | `15874f171a62eb39ce4acee55cdd732c06a3f57180dcbe3eb96cbd01e1701d5f` | +| `desktop/target/release/pakarcontroller.exe` | `4ec7b28d21f3f9d4e5d8208456b15b5d8edfdc07018fdf6c867b3ab28612823d` | +| `mobile/build/app/outputs/flutter-apk/app-release.apk` | `81006c55469a7d97f8055d73b9ec3ba949257cf16bdba51b07fc81f20c053ba5` | + +S3 candidate: table-selection, buffered UART, 16 MB flash/OPI PSRAM, GPIO4, +both LED backends/GPIO default, BLE disabled, Ethernet included, ISP included, +preserved recovery bundle. Flash 2,312,627 / 3,342,336 bytes; static RAM 138,524 / +327,680 bytes. These are build figures, not free runtime heap. + +AVR candidate: table-selection/buffered/no RF; 28,668 bytes flash and 1,488 bytes +static SRAM. Do not apply this profile to a radio-enabled keycontroller. The +preserved recovery bundle was not silently replaced with this candidate. + +Android uses the existing preview signing identity and production-hold build +flag. It is not a new store release or evidence of installation. + +## Before deployment acceptance + +Identify the actual target and its profile, retain settings/rules/scene, then +perform an authorized update. Verify actual diagnostic-to-correction handoff, +preview/save/discard, streamed effects controls, pinch/pan on phone/tablet, +and audible command latency. Run the host examples only with explicit permission +to change visible output. The example Snake is a host-owned autonomous trail, +not a complete interactive game; the protocol permits richer host mini-apps. + +Production issue #2 stays open. No board reboot, flash, scene mutation, app +replacement or optical/audio acceptance occurred during this checkpoint. diff --git a/docs/FILE-COMPARISON.md b/docs/FILE-COMPARISON.md index 91cca8dc..537a20ce 100644 --- a/docs/FILE-COMPARISON.md +++ b/docs/FILE-COMPARISON.md @@ -2,9 +2,9 @@ Whole-repository superiority is **not established**. This report exposes retained code, scoped improvements, omissions and gaps; it does not turn missing reviews into exclusions. -Routing: 1506 paths have no specialist capability match. They remain visible through `consult-reuse.ps1 -Concern donor-audit` and issue #3; lack of a keyword match is NOT an exclusion. Every CSV row includes capability routing and owning issue links. +Routing: 1475 paths have no specialist capability match. They remain visible through `consult-reuse.ps1 -Concern donor-audit` and issue #3; lack of a keyword match is NOT an exclusion. Every CSV row includes capability routing and owning issue links. -Inventory: 4697 files; 391 complete source-file comparisons; 4306 pending comparisons; 289 compared files with known parity gaps. Header/implementation pairs can describe the same gap. Missing sparse files remain pending. +Inventory: 4732 files; 430 complete source-file comparisons; 4302 pending comparisons; 310 compared files with known parity gaps. Header/implementation pairs can describe the same gap. Missing sparse files remain pending. Every inventoried path, source revision/hash, target/proof file and line, omitted code, disposition and concrete reason is in `file-comparison.csv`. Hash-bound manual decisions are in `file-comparison-evidence.json`. Donor SHA-256 values bind original bytes; target/proof SHA-256 values bind UTF-8 without BOM with LF newlines. Anchors are recomputed into exact line numbers. Test-code presence is not test execution; execution receipts are in `FILE-COMPARISON-RECEIPTS.md`. @@ -14,9 +14,11 @@ Every inventoried path, source revision/hash, target/proof file and line, omitte |---|---:|---:|---:|---:| | ControllerBoardMini | 19 | 19 | 0 | 8 | | ControllerBoardMini/Puzzles | 18 | 18 | 0 | 8 | +| DRS-jSnake | 18 | 16 | 2 | 16 | | esp32WS2811 | 24 | 16 | 8 | 9 | -| PCController | 1517 | 115 | 1402 | 103 | -| Pealayer | 639 | 63 | 576 | 45 | +| PCController | 1517 | 118 | 1399 | 106 | +| Pealayer | 639 | 66 | 573 | 47 | +| portable-shell | 17 | 17 | 0 | 0 | | PU850-ESP | 109 | 109 | 0 | 96 | | PuzzleBoard | 22 | 22 | 0 | 1 | | RayanLamp | 2349 | 29 | 2320 | 19 | @@ -27,7 +29,7 @@ Every inventoried path, source revision/hash, target/proof file and line, omitte Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `15e208f1026c9c6bcc144d842cf4d2df0bd32b9aea5b169f486dc03fe45eafd5`. -- Target: `tools/build-firmware.ps1:165 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor=MiniCore:avr:328:bootloader=uart0]`. +- Target: `tools/build-firmware.ps1:188 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor=MiniCore:avr:328:bootloader=uart0]`. - Retained: Read the complete Timer donor profile. Its MiniCore ATmega328P board, UART0 Urboot, EEPROM keep, 115200 bootloader baud, modelP, 2.7 V BOD, size-LTO and external 16 MHz selections match the Puzzles profile and are retained by the canonical AVR build owner. - Omitted/replaced code: `fixed Puzzle_WithTimer.ino, COM12 port and USBasp programmer`. Disposition: **replaced**. Reason: The combined build selects its role/target and the currently inventoried device explicitly; retaining a retired Timer sketch name or stale host COM/programmer assumption could build or write the wrong target. - Omitted/replaced code: `editor-owned build output`. Disposition: **replaced**. Reason: The guarded wrapper owns one canonical low-write output/cache layout instead of allowing an editor extension to create an untracked duplicate build tree. @@ -40,7 +42,7 @@ Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `f94454e8edf978f7eb7d5ac1ed93c06186b35a03ac5e81b314855ba6a0551e9d`. - Target: `.vscode/settings.json:5 [UTF8-LF sha256=2373a0d9d9a7af54872b0ae854ccf1ff95116ef8f80aebfad513fe3031914d5f; anchor="*.ino": "cpp"]`. -- Target: `tools/build-firmware.ps1:212 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor='compile', '--jobs', "$Jobs", '--fqbn']`. +- Target: `tools/build-firmware.ps1:235 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor='compile', '--jobs', "$Jobs", '--fqbn']`. - Retained: Read all 539 Timer donor lines through EOF and confirmed byte identity with the reviewed Puzzles snapshot. AVR/Arduino language ownership and compile-profile intent are retained by portable editor associations and the actual MiniCore compile command. - Omitted/replaced code: `absolute Arduino15 compiler/core/library paths and copied avr-g++ predefined macros`. Disposition: **replaced**. Reason: Generated host/version paths and compiler-owned macros go stale and can misdescribe the selected MCU. The canonical build resolves the installed pinned toolchain and supplies the real FQBN/options. - Omitted/replaced code: `gcc-x64 IntelliSense mode and USBCON define for ATmega328P`. Disposition: **replaced**. Reason: Those generated values do not describe the AVR target ABI and can falsely advertise USB capability; real compiler invocation remains authoritative. @@ -62,19 +64,21 @@ Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte Verdict: **parity-gap**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `9ceb7e764ef9d4d5feb62b2c7a22eed59d614b7b56432fae87ce87b4aa434578`. -- Target: `firmware/avr/avr.ino:561 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=void serviceInputs(]`. -- Target: `firmware/avr/avr.ino:545 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=void boardKeyGesture(]`. +- Target: `firmware/avr/avr.ino:586 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=void serviceInputs(]`. +- Target: `firmware/avr/avr.ino:564 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=void boardKeyGesture(]`. - Retained: Read the complete Timer implementation and confirmed byte identity with Puzzles. Its active-low virtual/physical merge, stable-change callback and pressed callback semantics are retained by the current mode-aware AVR input owner. +- Retained: Handoff AVR role guard: the shared callback retains table input/menu handling without linking first-wins-only ownership state; keycontroller still forwards Down/Up exclusively while the ESP owns the game. Actual callback is compiled/executed for both role flags; no saved state or input timing changed. - Omitted/replaced code: `fixed 50 ms debounce and callback-only press surface`. Disposition: **pending**. Reason: The configurable classifier publishes game-relevant Down/Up plus gestures, but its faster default remains subject to physical bounce and latency acceptance. - Omitted/replaced code: `global registerIn/virtualRegister dependency`. Disposition: **replaced**. Reason: The typed ShiftRegisters owner supplies bounded samples and virtual inputs without mutable donor globals or macro double evaluation. +- Test-code evidence: `tests/key_minigame_integration_test.mjs:9 [UTF8-LF sha256=ae2a46216f0f3f907965041627cf2566743bd1304035efd2a8e0429cfbbb9dcb; anchor=test('actual AVR key callback]`. - Remaining: Perform the four-box physical key-order, bounce, release and UART latency test. ### ControllerBoardMini: `Timer/LocalLib/Keys.h` Verdict: **parity-gap**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `caa21fe97e3df262f0821bd582b60f2895a14ec193d7d6fec13321afe4c5a46d`. -- Target: `firmware/avr/avr.ino:561 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=void serviceInputs(]`. -- Target: `firmware/avr/avr.ino:545 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=void boardKeyGesture(]`. +- Target: `firmware/avr/avr.ino:586 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=void serviceInputs(]`. +- Target: `firmware/avr/avr.ino:564 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=void boardKeyGesture(]`. - Retained: Read the complete Timer header and confirmed byte identity with the reviewed Puzzles key API. Active-low physical/virtual input state, stable transitions, callbacks and releases are retained through the mode-aware classifier and board sample adapter. - Omitted/replaced code: `fixed DEBOUNCE_MS = 50`. Disposition: **pending**. Reason: The target uses configurable PAKAR_KEY_DEBOUNCE_MS (default 20 ms), but physical bounce and switch-to-UART latency still need measurement on the four-box board. - Omitted/replaced code: `Key virtual readCurrentState/onStateChanged and raw lower-four order`. Disposition: **replaced**. Reason: One sample adapter feeds the imported classifier; first-wins uses direct donor input order while the table role retains its separate PCController order. @@ -114,7 +118,7 @@ Verdict: **parity-gap**. Revision: `filesystem-snapshot`. Original-byte SHA-256: Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `57a8d5e6cc1c381585caa12c094816c3c79e44203b5ea3c7036e97b74175d467`. - Target: `firmware/avr/ShiftRegisters.cpp:55 [UTF8-LF sha256=990c51a59be04c29618232bd86f2857f9fddc65eb70d7585ad2f537c8a46b902; anchor=void ShiftRegisters::service()]`. -- Target: `firmware/avr/avr.ino:35 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=constexpr uint16_t kShiftPeriodMs = PAKAR_FIRST_WINS_ENABLED ? 1 : 5]`. +- Target: `firmware/avr/avr.ino:35 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=constexpr uint16_t kShiftPeriodMs = PAKAR_FIRST_WINS_ENABLED ? 1 : 5]`. - Retained: Read the complete byte-identical Timer/Puzzles implementation. Safe active-low initialization, disabled output while staging, reset release, shared-clock LSB-first output, parallel-load pulse and MSB-first input capture remain intact. - Omitted/replaced code: `Arduino shiftOut/shiftIn and unbounded every-loop service`. Disposition: **replaced**. Reason: Fixed direct-port transfers retain bit order/latch/load sequencing; first-wins uses bounded 1 ms sampling while table mode retains 5 ms. - Test-code evidence: `tests/key_input_order_test.cpp:32 [UTF8-LF sha256=fc1b99ef668cae2692a3317df5c345b544f702207f85646b5e88871c0ce2292a; anchor=shifted==0x5a]`. @@ -140,7 +144,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `libraries/BoardCore/src/Tasks.h:11 [UTF8-LF sha256=1726bd6aed88da9964bfce4bbc27becb58e10f85acc4b5b04d1b20741b6e7e74; anchor=class Tasks]`. - Target: `libraries/BoardCore/src/Tasks.cpp:7 [UTF8-LF sha256=55aac682e670f2aa62832818c60d45d508c85729aca242ff306439e9f4a1fb8b; anchor=void Tasks::update(]`. -- Target: `firmware/avr/avr.ino:191 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=cancelDeferredReset(); // A previously ACKed reset]`. +- Target: `firmware/avr/avr.ino:193 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=cancelDeferredReset(); // A previously ACKed reset]`. - Retained: Fully read original donor. Actual PCController context/occupancy/count/cancel/clear/deadline code adapted in existing BoardCore implementation; Puzzles/Timer ten-slot capacity restored. Actual AVR loop services the scheduler and uses it for the100ms Reset delay, canceled on Prog entry. - Retained: Cancellation/clear suppress later due work, callback-added/replaced slots wait for next outer pass, recursive update is blocked, zero/exact/wrapped deadlines work and half-range delays reject. - Omitted/replaced code: `Puzzles startsAt==0 sentinel, strict now > deadline, null callbacks and post-callback itemCount decrement; PCController eight slots, implicit millis-only addTask and same-pass additions`. Disposition: **replaced**. Reason: Use newer explicit occupancy/pre-callback retirement, ten-slot capacity, optional shared command clock, half-range bound and eligibility mask. Zero/wrap/reentrant/canceled/replaced/new callback regressions execute actual production code; no heap or callback batch copy. @@ -156,7 +160,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `libraries/BoardCore/src/Tasks.h:11 [UTF8-LF sha256=1726bd6aed88da9964bfce4bbc27becb58e10f85acc4b5b04d1b20741b6e7e74; anchor=class Tasks]`. - Target: `libraries/BoardCore/src/Tasks.cpp:7 [UTF8-LF sha256=55aac682e670f2aa62832818c60d45d508c85729aca242ff306439e9f4a1fb8b; anchor=void Tasks::update(]`. -- Target: `firmware/avr/avr.ino:191 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=cancelDeferredReset(); // A previously ACKed reset]`. +- Target: `firmware/avr/avr.ino:193 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=cancelDeferredReset(); // A previously ACKed reset]`. - Retained: Fully read original donor. Actual PCController context/occupancy/count/cancel/clear/deadline code adapted in existing BoardCore implementation; Puzzles/Timer ten-slot capacity restored. Actual AVR loop services the scheduler and uses it for the100ms Reset delay, canceled on Prog entry. - Retained: Cancellation/clear suppress later due work, callback-added/replaced slots wait for next outer pass, recursive update is blocked, zero/exact/wrapped deadlines work and half-range delays reject. - Omitted/replaced code: `Puzzles startsAt==0 sentinel, strict now > deadline, null callbacks and post-callback itemCount decrement; PCController eight slots, implicit millis-only addTask and same-pass additions`. Disposition: **replaced**. Reason: Use newer explicit occupancy/pre-callback retirement, ten-slot capacity, optional shared command clock, half-range bound and eligibility mask. Zero/wrap/reentrant/canceled/replaced/new callback regressions execute actual production code; no heap or callback batch copy. @@ -170,12 +174,12 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `97954eecd111d4c1bd092488ee395b7ec8eb4630ed507c9177af9fba6d434fc5`. -- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=78d374b149b483bc8203a0e879c3cf10905d1e9b8ab2a3dfc6becc2d15104746; anchor=bool enqueue]`. -- Target: `firmware/avr/TonePlayer.cpp:70 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=startsAt = stepEndsAt_]`. +- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=d8fb0cfc7a73a9c10bc378d85426fda01894c9991dc827ef9ed0cb5a72357c98; anchor=bool enqueue]`. +- Target: `firmware/avr/TonePlayer.cpp:87 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=startsAt = stepEndsAt_]`. - Retained: Read the complete byte-identical Timer/Puzzles implementation. Queue order, rests, bounded ten-step capacity and stop semantics are retained. - Omitted/replaced code: `busy wait, global mutable queue and repeated millis deadline`. Disposition: **replaced**. Reason: A nonblocking object-owned queue uses one loop owner and rollover-safe deadlines so UART/key services are not stalled. - Omitted/replaced code: `unbounded frequency and zero-duration acceptance`. Disposition: **replaced**. Reason: Requests outside the explicit hardware/wire range are rejected rather than silently producing unreliable output. -- Test-code evidence: `tests/tone_player_test.cpp:50 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.setMuted(false)]`. +- Test-code evidence: `tests/tone_player_test.cpp:51 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.setMuted(false)]`. - Scoped advantage: Nonblocking hardware-timer playback avoids the donor's loop starvation and deadline drift. - Remaining: Physically verify exact frequencies, tempo, flash synchronization, mute and cancellation on the four-box board. @@ -183,12 +187,12 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `9f2aea4eadd0a4112ba62d4bdac906a9749026aa4e69404c2ff3169667f204df`. -- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=78d374b149b483bc8203a0e879c3cf10905d1e9b8ab2a3dfc6becc2d15104746; anchor=bool enqueue]`. -- Target: `firmware/avr/TonePlayer.cpp:70 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=startsAt = stepEndsAt_]`. +- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=d8fb0cfc7a73a9c10bc378d85426fda01894c9991dc827ef9ed0cb5a72357c98; anchor=bool enqueue]`. +- Target: `firmware/avr/TonePlayer.cpp:87 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=startsAt = stepEndsAt_]`. - Retained: Read the complete byte-identical Timer/Puzzles header. Ten queued tones/pauses, PB1/D9 buzzer ownership and pitch constants remain available. - Omitted/replaced code: `waitForTonesEnd blocking loop`. Disposition: **replaced**. Reason: Nonblocking update/isBusy preserves completion while UART, input and front-panel services continue. - Omitted/replaced code: `global queue/functions and millis()+duration deadline`. Disposition: **replaced**. Reason: Object ownership plus rollover-safe anchored deadlines prevents namespace collision, drift and loop starvation. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. - Scoped advantage: Hardware compare toggling and nonblocking anchored deadlines retain cues while keeping the real-time control loop responsive. - Remaining: Register tests are not audible clock, melody-synchronization or transducer proof. @@ -196,8 +200,8 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **parity-gap**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `0f7fec7879abd26dbf2d56df28441727ea76fcb9e093f009d39f20cae77832bb`. -- Target: `firmware/esp/src/LedGrid.cpp:28 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=FastLED.addLeds deadline, null callbacks and post-callback itemCount decrement; PCController eight slots, implicit millis-only addTask and same-pass additions`. Disposition: **replaced**. Reason: Use newer explicit occupancy/pre-callback retirement, ten-slot capacity, optional shared command clock, half-range bound and eligibility mask. Zero/wrap/reentrant/canceled/replaced/new callback regressions execute actual production code; no heap or callback batch copy. @@ -409,7 +415,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `libraries/BoardCore/src/Tasks.h:11 [UTF8-LF sha256=1726bd6aed88da9964bfce4bbc27becb58e10f85acc4b5b04d1b20741b6e7e74; anchor=class Tasks]`. - Target: `libraries/BoardCore/src/Tasks.cpp:7 [UTF8-LF sha256=55aac682e670f2aa62832818c60d45d508c85729aca242ff306439e9f4a1fb8b; anchor=void Tasks::update(]`. -- Target: `firmware/avr/avr.ino:191 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=cancelDeferredReset(); // A previously ACKed reset]`. +- Target: `firmware/avr/avr.ino:193 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=cancelDeferredReset(); // A previously ACKed reset]`. - Retained: Fully read original donor. Actual PCController context/occupancy/count/cancel/clear/deadline code adapted in existing BoardCore implementation; Puzzles/Timer ten-slot capacity restored. Actual AVR loop services the scheduler and uses it for the100ms Reset delay, canceled on Prog entry. - Retained: Cancellation/clear suppress later due work, callback-added/replaced slots wait for next outer pass, recursive update is blocked, zero/exact/wrapped deadlines work and half-range delays reject. - Omitted/replaced code: `Puzzles startsAt==0 sentinel, strict now > deadline, null callbacks and post-callback itemCount decrement; PCController eight slots, implicit millis-only addTask and same-pass additions`. Disposition: **replaced**. Reason: Use newer explicit occupancy/pre-callback retirement, ten-slot capacity, optional shared command clock, half-range bound and eligibility mask. Zero/wrap/reentrant/canceled/replaced/new callback regressions execute actual production code; no heap or callback batch copy. @@ -423,14 +429,14 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `97954eecd111d4c1bd092488ee395b7ec8eb4630ed507c9177af9fba6d434fc5`. -- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=78d374b149b483bc8203a0e879c3cf10905d1e9b8ab2a3dfc6becc2d15104746; anchor=bool enqueue]`. -- Target: `firmware/avr/TonePlayer.cpp:70 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=startsAt = stepEndsAt_]`. +- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=d8fb0cfc7a73a9c10bc378d85426fda01894c9991dc827ef9ed0cb5a72357c98; anchor=bool enqueue]`. +- Target: `firmware/avr/TonePlayer.cpp:87 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=startsAt = stepEndsAt_]`. - Retained: Ten queued tones/pauses and PB1/D9 buzzer ownership; note constants retained in pitches.h. - Omitted/replaced code: `waitForTonesEnd blocking loop`. Disposition: **replaced**. Reason: Nonblocking update() plus isBusy() preserves completion observation while allowing UART, input and front-panel services to run; a busy wait would starve those required services. - Omitted/replaced code: `Global addTone/resetTone/playToneSequence state/functions and millis()+duration deadline`. Disposition: **replaced**. Reason: Object-owned enqueue/stop/update retains queue lifecycle and avoids namespace/global ownership collisions; deadline anchoring and signed rollover comparisons replace accumulating loop delay. - Omitted/replaced code: `Unbounded frequency and zero-duration acceptance`. Disposition: **replaced**. Reason: Requests outside the explicit hardware/wire range are rejected rather than accepted without reliable output. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. -- Test-code evidence: `tests/tone_player_test.cpp:50 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.setMuted(false)]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/tone_player_test.cpp:51 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.setMuted(false)]`. - Scoped advantage: Hardware compare toggling replaces the older donor software approach; nonblocking deadline anchoring passes late-call/rollover tests. - Remaining: Register-double tests are not audible or physical clock proof. @@ -438,14 +444,14 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `9f2aea4eadd0a4112ba62d4bdac906a9749026aa4e69404c2ff3169667f204df`. -- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=78d374b149b483bc8203a0e879c3cf10905d1e9b8ab2a3dfc6becc2d15104746; anchor=bool enqueue]`. -- Target: `firmware/avr/TonePlayer.cpp:70 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=startsAt = stepEndsAt_]`. +- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=d8fb0cfc7a73a9c10bc378d85426fda01894c9991dc827ef9ed0cb5a72357c98; anchor=bool enqueue]`. +- Target: `firmware/avr/TonePlayer.cpp:87 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=startsAt = stepEndsAt_]`. - Retained: Ten queued tones/pauses and PB1/D9 buzzer ownership; note constants retained in pitches.h. - Omitted/replaced code: `waitForTonesEnd blocking loop`. Disposition: **replaced**. Reason: Nonblocking update() plus isBusy() preserves completion observation while allowing UART, input and front-panel services to run; a busy wait would starve those required services. - Omitted/replaced code: `Global addTone/resetTone/playToneSequence state/functions and millis()+duration deadline`. Disposition: **replaced**. Reason: Object-owned enqueue/stop/update retains queue lifecycle and avoids namespace/global ownership collisions; deadline anchoring and signed rollover comparisons replace accumulating loop delay. - Omitted/replaced code: `Unbounded frequency and zero-duration acceptance`. Disposition: **replaced**. Reason: Requests outside the explicit hardware/wire range are rejected rather than accepted without reliable output. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. -- Test-code evidence: `tests/tone_player_test.cpp:50 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.setMuted(false)]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/tone_player_test.cpp:51 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.setMuted(false)]`. - Scoped advantage: Hardware compare toggling replaces the older donor software approach; nonblocking deadline anchoring passes late-call/rollover tests. - Remaining: Register-double tests are not audible or physical clock proof. @@ -453,8 +459,8 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **parity-gap**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `0f7fec7879abd26dbf2d56df28441727ea76fcb9e093f009d39f20cae77832bb`. -- Target: `firmware/esp/src/LedGrid.cpp:28 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=FastLED.addLeds=0.7VDD and shows both one-LED and three-series-LED outputs. Digital GPIO loopback cannot validate voltage at strip DIN, cable/reflection margin, downstream reset/reshaping, exact chip revision or physical address count. User optical first23-cell failure remains authoritative; require deployed pad captures plus physical/analogue measurements as needed. - Test-code evidence: `tests/idf_rmt_ring_test.cpp:210 [UTF8-LF sha256=67b7f1107160e49db96f06907edb16dc540095bf4aaf6555041d49f47362cf00; anchor=void signalCaptureChecks()]`. -- Test-code evidence: `tests/led_worker_test.cpp:377 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=Fixture::rxCorruptBit=23*6*24]`. +- Test-code evidence: `tests/led_worker_test.cpp:377 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=Fixture::rxCorruptBit=23*6*24]`. - Remaining: Deploy the version-consistent candidate, capture full-frame output under real Wi-Fi load, verify all480 addressed pixels and obtain user optical feedback. Timing margin adjustment is a candidate fix, not a proven root cause or physical completion. ### esp32WS2811: `LICENSE` @@ -519,7 +709,7 @@ Verdict: **equivalent-file**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad57 Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `7f169ec9c5b57f858d9d79c5059e97b3be4130930f52257691c7940f39a49dea`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful Aurora visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `Aurora painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful six weighted moving waves remain unported. Original allocates Colour per wave/pixel, frees scalar new Colour with delete[], repeatedly replaces heap waves and delays20ms. Port into bounded loop-owned wave state and shared effect capabilities/API/USB/persistence/all-client selectors; no allocator bug, heap churn or hidden50fps cap. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -530,7 +720,7 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `d175558be657cb27501c5687ad56b5f9f37dd6cb7490ba0de42298a628fb7aff`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful Aurora visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `Aurora painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful six weighted moving waves remain unported. Original allocates Colour per wave/pixel, frees scalar new Colour with delete[], repeatedly replaces heap waves and delays20ms. Port into bounded loop-owned wave state and shared effect capabilities/API/USB/persistence/all-client selectors; no allocator bug, heap churn or hidden50fps cap. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -541,7 +731,7 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `45e281ccc376bc13033723bc0b5258c399bd92043ae68db4ce376103ebe83876`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful Autumn visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `Autumn painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful random-origin palette-spread interpolation remains unported. Original origin can be read before first initialization, divides by steps/numLeds/2 without positive bounds and advances by loop count. Port validated dimensions/positive durations and elapsed-time interpolation into the existing renderer/catalog. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -552,7 +742,7 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `16d75c16e482c44b52feae753ec1ccdf3ab35fc50707e7f6a713462059ac544f`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful Autumn visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `Autumn painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful random-origin palette-spread interpolation remains unported. Original origin can be read before first initialization, divides by steps/numLeds/2 without positive bounds and advances by loop count. Port validated dimensions/positive durations and elapsed-time interpolation into the existing renderer/catalog. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -563,7 +753,7 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `039da26c04c5a6cf2438d42372f1f9d9d03748db1aee5b21fc8a0dedd0085c5b`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful Circus visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `Circus painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful random twelve-colour repaint remains unported. Adapt requested repaint interval as a monotonic deadline, not delay(interval) or another effect task, and expose through the shared acknowledged effect catalog; not a new default or automatic flashing diagnostic. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -574,7 +764,7 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `50e04e72e5fac9ab5130d83178a9962397f8634418dc6d8a25dee2327ba6e454`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful Circus visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `Circus painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful random twelve-colour repaint remains unported. Adapt requested repaint interval as a monotonic deadline, not delay(interval) or another effect task, and expose through the shared acknowledged effect catalog; not a new default or automatic flashing diagnostic. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -584,14 +774,14 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `6bf0bac344bd60971564e66252e5d23b964606c6b4ec67c77e68d1186a313f58`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:201 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=void LedOutput::work(){]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:208 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=void LedOutput::work(){]`. - Target: `firmware/esp32s3/src/LedOutput.h:89 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=Frame producer_{};]`. -- Target: `firmware/esp/src/LedGrid.cpp:591 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::showStaged() {]`. +- Target: `firmware/esp/src/LedGrid.cpp:606 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::showStaged() {]`. - Target: `firmware/esp32s3/src/LedWire.h:40 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t resetMicros=300;]`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. - Retained: Complete RGB/HSV implementation/header and palette read through EOF. Canonical RGB/FastLED rendering retains byte colours, saturated arithmetic and HSV rainbow without a second colour engine. No old class-level API or exact palette parity is falsely promised. - Omitted/replaced code: `colour legacy implementation/classes`. Disposition: **replaced**. Reason: Duplicate Colour/ColourHSV object and degree/percent float API, basic-colour wrapper names and custom saturated addition are replaced by existing FastLED CRGB/HSV/rainbow/qadd8 and validated canonical RGB bytes. Do not claim donor class/API drop-in or copy a second colour representation. Exact hue/palette aesthetics belong with the separately pending optional effects. -- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void pinSetupFailure(unsigned step)]`. +- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void pinSetupFailure(unsigned step)]`. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:8 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=function driverContract(source)]`. - Scoped advantage: Retain modern IDF5 compact immutable DMA source and explicit failures/LOW/latch, one renderer and bounded memory instead of legacy driver globals, per-effect tasks, blocking delays or duplicate colour ownership. - Remaining: Current480-pixel waveform/RF/refill/optical acceptance remains open; source hashes/fixtures are not current hardware proof. @@ -600,14 +790,14 @@ Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98a Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `f77e8491309317965731d826b1d20033185807f0f411f888bbb38958e1ad59b3`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:201 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=void LedOutput::work(){]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:208 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=void LedOutput::work(){]`. - Target: `firmware/esp32s3/src/LedOutput.h:89 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=Frame producer_{};]`. -- Target: `firmware/esp/src/LedGrid.cpp:591 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::showStaged() {]`. +- Target: `firmware/esp/src/LedGrid.cpp:606 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::showStaged() {]`. - Target: `firmware/esp32s3/src/LedWire.h:40 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t resetMicros=300;]`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. - Retained: Complete RGB/HSV implementation/header and palette read through EOF. Canonical RGB/FastLED rendering retains byte colours, saturated arithmetic and HSV rainbow without a second colour engine. No old class-level API or exact palette parity is falsely promised. - Omitted/replaced code: `colour legacy implementation/classes`. Disposition: **replaced**. Reason: Duplicate Colour/ColourHSV object and degree/percent float API, basic-colour wrapper names and custom saturated addition are replaced by existing FastLED CRGB/HSV/rainbow/qadd8 and validated canonical RGB bytes. Do not claim donor class/API drop-in or copy a second colour representation. Exact hue/palette aesthetics belong with the separately pending optional effects. -- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void pinSetupFailure(unsigned step)]`. +- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void pinSetupFailure(unsigned step)]`. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:8 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=function driverContract(source)]`. - Scoped advantage: Retain modern IDF5 compact immutable DMA source and explicit failures/LOW/latch, one renderer and bounded memory instead of legacy driver globals, per-effect tasks, blocking delays or duplicate colour ownership. - Remaining: Current480-pixel waveform/RF/refill/optical acceptance remains open; source hashes/fixtures are not current hardware proof. @@ -616,14 +806,14 @@ Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98a Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `2c27fa91967898ba4f4234c53889adfea11f3bd14a62147d64fdcb01837e6c2d`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:201 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=void LedOutput::work(){]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:208 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=void LedOutput::work(){]`. - Target: `firmware/esp32s3/src/LedOutput.h:89 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=Frame producer_{};]`. -- Target: `firmware/esp/src/LedGrid.cpp:591 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::showStaged() {]`. +- Target: `firmware/esp/src/LedGrid.cpp:606 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::showStaged() {]`. - Target: `firmware/esp32s3/src/LedWire.h:40 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t resetMicros=300;]`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. - Retained: Complete base-class/task source read through EOF. Existing pattern start/replace/stop remains one loop renderer, no per-effect task or blocking sleeps; waveform driver remains independently owned. - Omitted/replaced code: `lifecycle legacy implementation/classes`. Disposition: **replaced**. Reason: Per-effect FreeRTOS task creation/deletion, repeated cleanup of a non-cleared task handle and endless effect loops are replaced by existing loop-owned setPattern/stopPattern/service. Preserve single renderer and explicit frame publication without sleeping or a second engine; this is not an ABI-compatible installation of the old library. -- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void pinSetupFailure(unsigned step)]`. +- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void pinSetupFailure(unsigned step)]`. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:8 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=function driverContract(source)]`. - Scoped advantage: Retain modern IDF5 compact immutable DMA source and explicit failures/LOW/latch, one renderer and bounded memory instead of legacy driver globals, per-effect tasks, blocking delays or duplicate colour ownership. - Remaining: Current480-pixel waveform/RF/refill/optical acceptance remains open; source hashes/fixtures are not current hardware proof. @@ -632,14 +822,14 @@ Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98a Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `d8b948d7d5ddef65ee40f162b5cd1f52e7aff7a525c94dfa11f1846b053aed71`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:201 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=void LedOutput::work(){]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:208 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=void LedOutput::work(){]`. - Target: `firmware/esp32s3/src/LedOutput.h:89 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=Frame producer_{};]`. -- Target: `firmware/esp/src/LedGrid.cpp:591 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::showStaged() {]`. +- Target: `firmware/esp/src/LedGrid.cpp:606 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::showStaged() {]`. - Target: `firmware/esp32s3/src/LedWire.h:40 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t resetMicros=300;]`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. - Retained: Complete base-class/task source read through EOF. Existing pattern start/replace/stop remains one loop renderer, no per-effect task or blocking sleeps; waveform driver remains independently owned. - Omitted/replaced code: `lifecycle legacy implementation/classes`. Disposition: **replaced**. Reason: Per-effect FreeRTOS task creation/deletion, repeated cleanup of a non-cleared task handle and endless effect loops are replaced by existing loop-owned setPattern/stopPattern/service. Preserve single renderer and explicit frame publication without sleeping or a second engine; this is not an ABI-compatible installation of the old library. -- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void pinSetupFailure(unsigned step)]`. +- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void pinSetupFailure(unsigned step)]`. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:8 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=function driverContract(source)]`. - Scoped advantage: Retain modern IDF5 compact immutable DMA source and explicit failures/LOW/latch, one renderer and bounded memory instead of legacy driver globals, per-effect tasks, blocking delays or duplicate colour ownership. - Remaining: Current480-pixel waveform/RF/refill/optical acceptance remains open; source hashes/fixtures are not current hardware proof. @@ -649,7 +839,7 @@ Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98a Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `c70e8d8c7a43b87c4932dd57adb952278670df136f6a7a5349e7df1586dc8ed7`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful SnowSparkle visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `SnowSparkle painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful background/white sparkle/fade remains unported. Replace dynamic Sparkle pointer array and per-sparkle new/delete with fixed slots, wall-clock fades and shared acknowledged effect catalog, not delay(10) or another task. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -660,7 +850,7 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `a86ecaaf9b2e9a41fdb53aa047883ed1f72acabc9909c164aab10bade3aab4ce`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp/src/LedGrid.h:42 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=enum Pattern : uint8_t]`. +- Target: `firmware/esp/src/LedGrid.h:44 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=enum Pattern : uint8_t]`. - Retained: Complete source/header read through EOF; useful SnowSparkle visual algorithm retained as an explicit unimplemented parity gap, NOT claimed imported or advertised by a board. - Omitted/replaced code: `SnowSparkle painting algorithm and all-surface capability exposure`. Disposition: **pending**. Reason: Useful background/white sparkle/fade remains unported. Replace dynamic Sparkle pointer array and per-sparkle new/delete with fixed slots, wall-clock fades and shared acknowledged effect catalog, not delay(10) or another task. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:36 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=for(const effect of ['Aurora','Autumn','Circus','SnowSparkle'])]`. @@ -670,19 +860,19 @@ Verdict: **parity-gap**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. O Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `79fd1b8141cc8eb1473dcb1c25f754b0fbe71cab76af7a311fd10db0269e6511`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:201 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=void LedOutput::work(){]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:208 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=void LedOutput::work(){]`. - Target: `firmware/esp32s3/src/LedOutput.h:89 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=Frame producer_{};]`. -- Target: `firmware/esp/src/LedGrid.cpp:591 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::showStaged() {]`. +- Target: `firmware/esp/src/LedGrid.cpp:606 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::showStaged() {]`. - Target: `firmware/esp32s3/src/LedWire.h:40 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t resetMicros=300;]`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:96 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=bool LedOutput::requestBackend(]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:96 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=bool LedOutput::requestBackend(]`. - Retained: Complete original driver/API read through EOF. Existing stage/show/get-published-frame, full480 RGB, dedicated worker, coalescing and immutable TX ownership mapped function by function. Newly adapt explicit idle-LOW policy to both backends: reset, preload LOW, enable output, then allocate/init; each failed GPIO step stops before RMT/heap/TX with no false ready/black receipt. - Retained: Runtime-backend checkpoint reread the complete immutable 0222ef02 src/esp32WS2811.cpp and verified original-byte SHA79fd1b81. Adapt donor show/worker/semaphore full-frame ownership into one existing LedOutput worker with independently compiled GPIO and RMT/DMA implementations; renderer, corrected mapping and current RGB frame stay shared. A request records intent only: finish the current frame, quiesce capture, release pin/channel/encoder/DMA and power-management resources, initialize one driver, resend the newest complete frame including Off, then publish completed backend revision. When neither driver is compiled, the pin is left unclaimed; failures retain requested-versus-active truth and explicit fallback/error. - Omitted/replaced code: `driver legacy implementation/classes`. Disposition: **replaced**. Reason: Legacy driver/rmt.h configuration, original-ESP32 channel-memory topology, task-static 24-symbol-per-pixel expanded buffer, hardcoded GRB and ESP_ERROR_CHECK bodies are replaced, not installed alongside IDF5. Existing compact internal1440-byte bytes-encoder/SDK ring, scoped IRQ ownership, full-frame mailbox and recoverable failures retain the useful transport discipline. The donor's extra terminator is allocated but excluded from its write count; keep our explicit300us low latch instead. -- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void pinSetupFailure(unsigned step)]`. +- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void pinSetupFailure(unsigned step)]`. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:8 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=function driverContract(source)]`. -- Test-code evidence: `tests/led_worker_test.cpp:552 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void runtimeSwitch(){]`. -- Test-code evidence: `tests/led_worker_test.cpp:613 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void runtimeResources(){]`. +- Test-code evidence: `tests/led_worker_test.cpp:552 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void runtimeSwitch(){]`. +- Test-code evidence: `tests/led_worker_test.cpp:613 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void runtimeResources(){]`. - Test-code evidence: `tests/led_backend_integration_test.mjs:7 [UTF8-LF sha256=011bfd16a540618bea7a79a4d0977f99832738075bf896688d00df3717639689; anchor=const select=app.slice(]`. - Scoped advantage: Retain modern IDF5 compact immutable DMA source and explicit failures/LOW/latch, one renderer and bounded memory instead of legacy driver globals, per-effect tasks, blocking delays or duplicate colour ownership. Runtime drivers share one image and sole pin owner; completion waits for the first full resend/latch rather than allocation alone. - Remaining: Current480-pixel waveform/RF/refill/optical acceptance remains open; source hashes/fixtures are not current hardware proof. @@ -692,18 +882,42 @@ Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98a Verdict: **scoped-improvement**. Revision: `0222ef025f2b8e03f8c8aef35d63c545a98ad579`. Original-byte SHA-256: `f74e535287f38aee87bb9108fb85ebabb99f2421cd2828ecaef757b52ebb119e`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:201 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=void LedOutput::work(){]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:208 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=void LedOutput::work(){]`. - Target: `firmware/esp32s3/src/LedOutput.h:89 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=Frame producer_{};]`. -- Target: `firmware/esp/src/LedGrid.cpp:591 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::showStaged() {]`. +- Target: `firmware/esp/src/LedGrid.cpp:606 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::showStaged() {]`. - Target: `firmware/esp32s3/src/LedWire.h:40 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t resetMicros=300;]`. - Target: `docs/ESP32-WS2811-REUSE.md:1 [UTF8-LF sha256=56bf08b491e47cd61e040229ff2aa8a6b2a88b75d1f2636a49f0b91af15455c1; anchor=# esp32WS2811 source comparison]`. - Retained: Complete original driver/API read through EOF. Existing stage/show/get-published-frame, full480 RGB, dedicated worker, coalescing and immutable TX ownership mapped function by function. Newly adapt explicit idle-LOW policy to both backends: reset, preload LOW, enable output, then allocate/init; each failed GPIO step stops before RMT/heap/TX with no false ready/black receipt. - Omitted/replaced code: `driver legacy implementation/classes`. Disposition: **replaced**. Reason: Legacy driver/rmt.h configuration, original-ESP32 channel-memory topology, task-static 24-symbol-per-pixel expanded buffer, hardcoded GRB and ESP_ERROR_CHECK bodies are replaced, not installed alongside IDF5. Existing compact internal1440-byte bytes-encoder/SDK ring, scoped IRQ ownership, full-frame mailbox and recoverable failures retain the useful transport discipline. The donor's extra terminator is allocated but excluded from its write count; keep our explicit300us low latch instead. -- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void pinSetupFailure(unsigned step)]`. +- Test-code evidence: `tests/led_worker_test.cpp:234 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void pinSetupFailure(unsigned step)]`. - Test-code evidence: `tests/esp32_ws2811_reuse_test.mjs:8 [UTF8-LF sha256=3420e0bed7ebeee3aa7aa8366ad6195bd59d052504e62fd6dd2e5b22ff0fa006; anchor=function driverContract(source)]`. - Scoped advantage: Retain modern IDF5 compact immutable DMA source and explicit failures/LOW/latch, one renderer and bounded memory instead of legacy driver globals, per-effect tasks, blocking delays or duplicate colour ownership. - Remaining: Current480-pixel waveform/RF/refill/optical acceptance remains open; source hashes/fixtures are not current hardware proof. +### PCController: `.github/scripts/repository-check.mjs` + +Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `672c5cb926c46eb9f18232469c9ec9528b133eae52ecf0cb372ca443ca936560`. + +- Target: `tools/check-doc-links.mjs:77 [UTF8-LF sha256=f4fbe51d7ecffe9c764000d953e9e0e04a92d1bab5f8ef99c1fcb281adcac5a6; anchor=export function checkGuides]`. +- Retained: Actual localLinkReference and link/heading verification adapted to a dependency-free checker, including root/relative paths and explicit guide inventory. +- Retained: Reread both complete donor scripts for PR27 CodeQL feedback. Adapt the actual single-open-descriptor fstat/read/finally-close mechanism from repository-policy.mjs to guide and linked-file observation, eliminating pathname check/reopen races without suppressing missing-file or anchor errors. Bounded guide snapshots are cached only within one invocation. +- Omitted/replaced code: `Full-repository source/privacy/license/required-file scans`. Disposition: **pending**. Reason: Donor product paths and dual-license assertions cannot replace Pakar ownership or preserved third-party source. Full maintained-doc expansion and privacy policy parity remain useful pending work. +- Test-code evidence: `tests/doc_links_test.mjs:13 [UTF8-LF sha256=1f3a40d9c4fba2f5da02dc92a6637f8c073e07b10307cf5d8f405691902bba33; anchor=assert.deepEqual(checkGuides]`. +- Scoped advantage: Offline fixtures cover Unicode, malformed encoding, HTML branding links, fenced examples and path escape; no dependency install or remote crawl. +- Remaining: Expand maintained guide coverage and evaluate generic source/privacy policy without rewriting immutable donor history. + +### PCController: `.github/scripts/repository-policy.mjs` + +Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `6a5d6ae8dba31f2fbac91b193f7448264db5626a9ff71e0767cf5af9fb787877`. + +- Target: `tools/check-doc-links.mjs:77 [UTF8-LF sha256=f4fbe51d7ecffe9c764000d953e9e0e04a92d1bab5f8ef99c1fcb281adcac5a6; anchor=export function checkGuides]`. +- Retained: Actual heading-label cleanup, Unicode slugs, fenced example exclusion, explicit anchors and duplicate suffix handling imported; longer fences and suffix collisions hardened. +- Retained: Reread both complete donor scripts for PR27 CodeQL feedback. Adapt the actual single-open-descriptor fstat/read/finally-close mechanism from repository-policy.mjs to guide and linked-file observation, eliminating pathname check/reopen races without suppressing missing-file or anchor errors. Bounded guide snapshots are cached only within one invocation. +- Omitted/replaced code: `privacyFindings, actionPinFindings, readOrdinaryTextFile and generated-path policy`. Disposition: **pending**. Reason: Current Pakar governance already guards immutable Actions and its source contracts; donor-specific provenance allowlists would forbid user-requested donor evidence. Equivalent generic bounded privacy scanning is still pending. +- Test-code evidence: `tests/doc_links_test.mjs:13 [UTF8-LF sha256=1f3a40d9c4fba2f5da02dc92a6637f8c073e07b10307cf5d8f405691902bba33; anchor=assert.deepEqual(checkGuides]`. +- Scoped advantage: Offline fixtures cover Unicode, malformed encoding, HTML branding links, fenced examples and path escape; no dependency install or remote crawl. +- Remaining: Expand maintained guide coverage and evaluate generic source/privacy policy without rewriting immutable donor history. + ### PCController: `docs/Shared-Production-Firmware.md` Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `406a5a7072fe4a6f4be99de50903344027a7f9aa29858267beba978e2d60d8f3`. @@ -722,7 +936,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp/src/NtfyNotice.h:7 [UTF8-LF sha256=a58636a85b08b0837302455dbe7e38ef0dc4f2bfbbb288db3e7c326f2828007a; anchor=class NtfyNotice]`. - Target: `firmware/esp/src/NtfyService.cpp:73 [UTF8-LF sha256=d34bb2c00485dab92d5b3d6e5b883c8e278c710608dd67855b7735702f604258; anchor=void NtfyService::worker]`. - Target: `firmware/esp/src/UpdateProgress.h:33 [UTF8-LF sha256=cbf19051f9f71c1b5ff99cd024faa1a87e12a211bc6001a413cdfde6a84e8640; anchor=class Progress]`. -- Target: `firmware/esp/application.ino:2797 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=String localUpdateJson() {]`. +- Target: `firmware/esp/application.ino:2895 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=String localUpdateJson() {]`. - Target: `firmware/esp/src/RemoteFirmware.h:67 [UTF8-LF sha256=9c959a74a7d59909f38c48844e191a089cf1849663e78caa4a3fafcaff6b2251; anchor=PakarUpdate::Observation progress() const]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/update-firmware.ps1:5 [UTF8-LF sha256=76718dc025e115ce85ef86ae1014e875b9fcbc7c629276ff07a893868f75a56f; anchor=[ValidateSet('auto','local','remote')]]`. @@ -731,7 +945,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp/src/AvrIsp.h:66 [UTF8-LF sha256=03561fd6b5a97e9a178e3dd70888c8e068c7db58a357b26028353d959b5009ad; anchor=void report(]`. - Target: `mobile/lib/upgrade_observation.dart:3 [UTF8-LF sha256=8feabc680770f062a09ec3a5e7e72d1e9aad814335f630ddc00be3770790e8d7; anchor=class UpgradeObservation]`. - Target: `desktop/src/firmware_progress.rs:11 [UTF8-LF sha256=11188ec3c9bc7b89e76b94e5412f0e689fa656e5a636d4964136c2236aabbe31; anchor=pub struct Upgrade]`. -- Target: `firmware/esp/src/WebUi.h:2431 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function upgradeObservation(]`. +- Target: `firmware/esp/src/WebUi.h:2694 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function upgradeObservation(]`. - Retained: Complete canonical donor documentation read through EOF and original-byte SHA matched the inventory. Adapted the actual measured-stage/unknown-denominator/verified-completion/no-write-replay contract, not the donor's AVRDUDE parser: local HTTP raw file bytes, remote download stage bytes, coherent async-to-loop snapshots, finite AVR scheduled text leases and separate client reboot/readback verification. - Retained: Reread the complete canonical update-progress document for actual0.2.8 migration. Explicit exact-board maintenance admits only the older producer's update/backup observations, never ordinary controls; the new boot still passes the default release policy. Missing BOTH newer producer headers is confined to that selected recovery and retains same-address, whole MD5 and before/after board/boot checks. Failed manifest admission can preserve zero image metadata only with the exact pinned manifest/job/boot/target/zero bytes. Added backpressure-aware multipart chunks and explicit upload pacing; no automatic mutation retry. Real fixtures cover these boundaries. Actual HTTPS stage22/new boot/full running SHA and separate Internet control/WSS passed; final direct LAN WS interruption and the false completion_verified bit remain open, not fabricated success. - Retained: Local /update/info now uses boot-captured immutable image metadata rather than whole-image hashing on every progress poll. Status and WebSocket status/ping remain readable during local OTA; mutating commands remain inhibited. No authentication is enabled by the updater; it requires explicit auth-off readback before and after. @@ -745,7 +959,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `mobile/test/ntfy_settings_test.dart:12 [UTF8-LF sha256=1904fef52317a35ffd7f8e455af0491bb4e2410a763619a1544842227412bb7f; anchor=void main()]`. - Test-code evidence: `tests/update_progress_test.cpp:25 [UTF8-LF sha256=734cc8da7b58f1a1fc4bf825793be5883b15ec4709279ff23b43a1a9019b39df; anchor=Concurrent reader never observes]`. - Test-code evidence: `tests/front_panel_test.cpp:43 [UTF8-LF sha256=c1c9da7276ef26ae35c7ad6a927d751f177bf43774c1408a86b225f651550cd6; anchor=The ESP's actual progress encoder]`. -- Test-code evidence: `tests/update_tool_test.mjs:32 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=for(const scenario of]`. +- Test-code evidence: `tests/update_tool_test.mjs:32 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=for(const scenario of]`. - Test-code evidence: `tools/test-update.ps1:15 [UTF8-LF sha256=a666f20d3c0d9cd2b7bef2a2c0d31adc25c6dd29255f28c582cbe7a31aa8a8d9; anchor=$taskPanelOutput=]`. - Test-code evidence: `tests/upgrade_lifecycle_test.cpp:13 [UTF8-LF sha256=11a37f72b8e3f67a54572b1a7ffd73e559bf9dab0d8a5d62b18f9c3c3adf41a5; anchor=int main()]`. - Test-code evidence: `tests/local_ota_receipt_test.cpp:41 [UTF8-LF sha256=7441cc68b6aab96002d72b5e981fc056ec2da5818fc933efeac252cc46bea513; anchor=int main()]`. @@ -775,8 +989,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp32s3/src/LcdPort.cpp:3 [UTF8-LF sha256=97830b8c55a9a5214edfd537ab8426696f79625ab7cf30183b14066b19ef9c94; anchor=void LcdPort::begin(]`. - Target: `firmware/esp/src/LcdManagement.h:3 [UTF8-LF sha256=eb800ee4c334b22504f37bd841bda58a7562052df2c7226ff19bc23a454e652d; anchor=String lcdStatusJson()]`. - Target: `desktop/src/display.rs:7 [UTF8-LF sha256=9d73ae63aaa8a2bc4b824219c9cfab9f7924ae7566be7a9206c11b6344a2f18b; anchor=pub struct LcdObservation]`. -- Target: `mobile/lib/peer_context.dart:284 [UTF8-LF sha256=e2d91e078d6dad7454eaa68f9a030ef1aa8ae2c91ba72ba522818da6b74131b8; anchor=bool lcdObservationReady(]`. -- Target: `firmware/esp/src/WebUi.h:2260 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function lcdObservation(]`. +- Target: `mobile/lib/peer_context.dart:302 [UTF8-LF sha256=c8602a12ccd3009028068aba091d46900c8aa0e345a4426ffe80acfb716362fc; anchor=bool lcdObservationReady(]`. +- Target: `firmware/esp/src/WebUi.h:2520 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function lcdObservation(]`. - Retained: Complete actual donor source read through EOF. Adapted common PCF8574 0x27/0x3F discovery, 4-bit HD44780 wake/control bytes,16x2 ASCII row normalization/padding, desired frame cache and backlight bit mapping into a transport-independent production class. S3 owns dedicated GPIO15/16 hardware I2C so the confirmed AVR A4/A5 TM1637 and ISP pins remain untouched. - Omitted/replaced code: `Blocking50ms/4.5ms initialization, ignored expander return values and cache validity after failed writes`. Disposition: **replaced**. Reason: Initialization is a paced loop-owned state machine. Every expander write must ACK; no full frame/available status until all initialization and both rows complete. NACK revokes readiness, bounded retry reinitializes/replays current desired text. Missing modules cannot become initialized merely because initialize() returned. - Omitted/replaced code: `Flash-string overload and LCD clear/backlight/generic bus controls on all client surfaces; host-owned LCD prompt/priority/offline policy`. Disposition: **pending**. Reason: This slice implements standalone physical discovery/init/default cell/update text, finite REST presentation and exact completed-frame previews. It does not claim the full donor host presenter, raw I2C bridge, every app editor or USB LCD mutation parity. CompactI2c remains available for a future explicit nonconflicting AVR wiring/build profile after flash/RAM review. @@ -797,8 +1011,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp32s3/src/LcdPort.cpp:3 [UTF8-LF sha256=97830b8c55a9a5214edfd537ab8426696f79625ab7cf30183b14066b19ef9c94; anchor=void LcdPort::begin(]`. - Target: `firmware/esp/src/LcdManagement.h:3 [UTF8-LF sha256=eb800ee4c334b22504f37bd841bda58a7562052df2c7226ff19bc23a454e652d; anchor=String lcdStatusJson()]`. - Target: `desktop/src/display.rs:7 [UTF8-LF sha256=9d73ae63aaa8a2bc4b824219c9cfab9f7924ae7566be7a9206c11b6344a2f18b; anchor=pub struct LcdObservation]`. -- Target: `mobile/lib/peer_context.dart:284 [UTF8-LF sha256=e2d91e078d6dad7454eaa68f9a030ef1aa8ae2c91ba72ba522818da6b74131b8; anchor=bool lcdObservationReady(]`. -- Target: `firmware/esp/src/WebUi.h:2260 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function lcdObservation(]`. +- Target: `mobile/lib/peer_context.dart:302 [UTF8-LF sha256=c8602a12ccd3009028068aba091d46900c8aa0e345a4426ffe80acfb716362fc; anchor=bool lcdObservationReady(]`. +- Target: `firmware/esp/src/WebUi.h:2520 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function lcdObservation(]`. - Retained: Complete actual donor source read through EOF. Adapted common PCF8574 0x27/0x3F discovery, 4-bit HD44780 wake/control bytes,16x2 ASCII row normalization/padding, desired frame cache and backlight bit mapping into a transport-independent production class. S3 owns dedicated GPIO15/16 hardware I2C so the confirmed AVR A4/A5 TM1637 and ISP pins remain untouched. - Omitted/replaced code: `Blocking50ms/4.5ms initialization, ignored expander return values and cache validity after failed writes`. Disposition: **replaced**. Reason: Initialization is a paced loop-owned state machine. Every expander write must ACK; no full frame/available status until all initialization and both rows complete. NACK revokes readiness, bounded retry reinitializes/replays current desired text. Missing modules cannot become initialized merely because initialize() returned. - Omitted/replaced code: `Flash-string overload and LCD clear/backlight/generic bus controls on all client surfaces; host-owned LCD prompt/priority/offline policy`. Disposition: **pending**. Reason: This slice implements standalone physical discovery/init/default cell/update text, finite REST presentation and exact completed-frame previews. It does not claim the full donor host presenter, raw I2C bridge, every app editor or USB LCD mutation parity. CompactI2c remains available for a future explicit nonconflicting AVR wiring/build profile after flash/RAM review. @@ -821,8 +1035,8 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Retained: Bounded four-digit RAM/flash text, raw segments, integer/fixed-point output, caching and 0..7 brightness/display-off behavior. - Retained: Actual donor ACK sampling, one-second cached-frame/brightness keepalive, observed presence and active 70ms brightness service on PC4/PC5. Independent probing was removed. - Omitted/replaced code: `PB3/PB5 TM1637 bus pins`. Disposition: **replaced**. Reason: User-confirmed PuzzleBoard owns A4/SDA as DIO and A5/SCL as CLK. D11/D13 must remain free for ESP-driven ISP; copying PCController's pins would conflict with programming. -- Test-code evidence: `tests/pc_contract_test.cpp:291 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=void keysAndDisplay()]`. -- Test-code evidence: `firmware/avr/avr.ino:628 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=display.serviceBrightness(]`. +- Test-code evidence: `tests/pc_contract_test.cpp:294 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=void keysAndDisplay()]`. +- Test-code evidence: `firmware/avr/avr.ino:653 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=display.serviceBrightness(]`. - Scoped advantage: Retains donor recovery on the correct board pins, accumulates intermediate NACKs instead of letting the final control ACK hide failure; host disconnect/reconnect and true-off tests pass. - Remaining: Physical TM1637 unplug/replug and power-cycle acceptance remains unverified; host GPIO doubles are not a physical board. @@ -837,8 +1051,8 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Retained: Actual donor ACK sampling, one-second cached-frame/brightness keepalive, observed presence and active 70ms brightness service on PC4/PC5. Independent probing was removed. - Omitted/replaced code: `PB3/PB5 TM1637 bus pins`. Disposition: **replaced**. Reason: User-confirmed PuzzleBoard owns A4/SDA as DIO and A5/SCL as CLK. D11/D13 must remain free for ESP-driven ISP; copying PCController's pins would conflict with programming. - Omitted/replaced code: `New donor ATmega328P-only fast-port guard and AVR validation removal`. Disposition: **replaced**. Reason: Pakar AVR target remains explicitly ATmega328P, with its correct PC4/PC5 ports and retained sentinel/brightness validation on all branches. Removing checks only to mirror donor performance changes would weaken bounds without measured need. Other AVR targets are not advertised; future ports must bind their own pins and guard. -- Test-code evidence: `tests/pc_contract_test.cpp:291 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=void keysAndDisplay()]`. -- Test-code evidence: `firmware/avr/avr.ino:628 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=display.serviceBrightness(]`. +- Test-code evidence: `tests/pc_contract_test.cpp:294 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=void keysAndDisplay()]`. +- Test-code evidence: `firmware/avr/avr.ino:653 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=display.serviceBrightness(]`. - Scoped advantage: Retains donor recovery on the correct board pins, accumulates intermediate NACKs instead of letting the final control ACK hide failure; host disconnect/reconnect and true-off tests pass. - Remaining: Physical TM1637 unplug/replug and power-cycle acceptance remains unverified; host GPIO doubles are not a physical board. @@ -868,7 +1082,7 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Target: `libraries/BoardCore/src/Tasks.h:11 [UTF8-LF sha256=1726bd6aed88da9964bfce4bbc27becb58e10f85acc4b5b04d1b20741b6e7e74; anchor=class Tasks]`. - Target: `libraries/BoardCore/src/Tasks.cpp:7 [UTF8-LF sha256=55aac682e670f2aa62832818c60d45d508c85729aca242ff306439e9f4a1fb8b; anchor=void Tasks::update(]`. -- Target: `firmware/avr/avr.ino:191 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=cancelDeferredReset(); // A previously ACKed reset]`. +- Target: `firmware/avr/avr.ino:193 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=cancelDeferredReset(); // A previously ACKed reset]`. - Retained: Fully read original donor. Actual PCController context/occupancy/count/cancel/clear/deadline code adapted in existing BoardCore implementation; Puzzles/Timer ten-slot capacity restored. Actual AVR loop services the scheduler and uses it for the100ms Reset delay, canceled on Prog entry. - Retained: Cancellation/clear suppress later due work, callback-added/replaced slots wait for next outer pass, recursive update is blocked, zero/exact/wrapped deadlines work and half-range delays reject. - Omitted/replaced code: `Puzzles startsAt==0 sentinel, strict now > deadline, null callbacks and post-callback itemCount decrement; PCController eight slots, implicit millis-only addTask and same-pass additions`. Disposition: **replaced**. Reason: Use newer explicit occupancy/pre-callback retirement, ten-slot capacity, optional shared command clock, half-range bound and eligibility mask. Zero/wrap/reentrant/canceled/replaced/new callback regressions execute actual production code; no heap or callback batch copy. @@ -884,7 +1098,7 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Target: `libraries/BoardCore/src/Tasks.h:11 [UTF8-LF sha256=1726bd6aed88da9964bfce4bbc27becb58e10f85acc4b5b04d1b20741b6e7e74; anchor=class Tasks]`. - Target: `libraries/BoardCore/src/Tasks.cpp:7 [UTF8-LF sha256=55aac682e670f2aa62832818c60d45d508c85729aca242ff306439e9f4a1fb8b; anchor=void Tasks::update(]`. -- Target: `firmware/avr/avr.ino:191 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=cancelDeferredReset(); // A previously ACKed reset]`. +- Target: `firmware/avr/avr.ino:193 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=cancelDeferredReset(); // A previously ACKed reset]`. - Retained: Fully read original donor. Actual PCController context/occupancy/count/cancel/clear/deadline code adapted in existing BoardCore implementation; Puzzles/Timer ten-slot capacity restored. Actual AVR loop services the scheduler and uses it for the100ms Reset delay, canceled on Prog entry. - Retained: Cancellation/clear suppress later due work, callback-added/replaced slots wait for next outer pass, recursive update is blocked, zero/exact/wrapped deadlines work and half-range delays reject. - Omitted/replaced code: `Puzzles startsAt==0 sentinel, strict now > deadline, null callbacks and post-callback itemCount decrement; PCController eight slots, implicit millis-only addTask and same-pass additions`. Disposition: **replaced**. Reason: Use newer explicit occupancy/pre-callback retirement, ten-slot capacity, optional shared command clock, half-range bound and eligibility mask. Zero/wrap/reentrant/canceled/replaced/new callback regressions execute actual production code; no heap or callback batch copy. @@ -898,13 +1112,13 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `08b41c4240ec899a3184525a864740b2762e7e675d75302c0adbdb27b4a6e511`. -- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=78d374b149b483bc8203a0e879c3cf10905d1e9b8ab2a3dfc6becc2d15104746; anchor=bool enqueue]`. -- Target: `firmware/avr/TonePlayer.cpp:70 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=startsAt = stepEndsAt_]`. +- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=d8fb0cfc7a73a9c10bc378d85426fda01894c9991dc827ef9ed0cb5a72357c98; anchor=bool enqueue]`. +- Target: `firmware/avr/TonePlayer.cpp:87 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=startsAt = stepEndsAt_]`. - Target: `desktop/src/board_silent.rs:128 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=pub struct Transaction]`. - Target: `desktop/src/ui/preferences.rs:262 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn board_controls(]`. -- Target: `mobile/lib/api.dart:1882 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future boardSound(]`. +- Target: `mobile/lib/api.dart:2109 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future boardSound(]`. - Target: `mobile/lib/board_sound_preferences.dart:10 [UTF8-LF sha256=888e518c1a85ff594b6c4c778f2eb770a4cbcad3a1d53d3defea19b5e226dc5d; anchor=class BoardSoundPreferences]`. -- Target: `firmware/esp/src/WebUi.h:3124 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=async function boardSoundTransaction(]`. +- Target: `firmware/esp/src/WebUi.h:3542 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=async function boardSoundTransaction(]`. - Target: `firmware/avr/BootMelody.cpp:37 [UTF8-LF sha256=fad1505f9cedc196236fc782ae2ec71a3c9fb8784659a1955affdf9e44137247; anchor=case kCueAttention:]`. - Target: `libraries/BoardCore/src/AttentionCues.h:13 [UTF8-LF sha256=02693199ab57f58a0531f46d8afa05ccd46dbd4c28b8db50b55bf5e4d1cff7b6; anchor=class Upload]`. - Target: `tools/test-audio.ps1:18 [UTF8-LF sha256=41500a0595fe030dd36f70175b56ee6f16ff62fe3bd1b2d9bf9bb29e94f90b30; anchor=Audio ·]`. @@ -915,14 +1129,14 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Retained: The existing Timer1/nonblocking player now also owns a finite 240ms attention cue, additive ID14; cue count is ACK-gated across all clients. Silent/Prog behavior and original melodies remain unchanged. - Omitted/replaced code: `now + step.durationMs; nondelegating default constructor; stale idle active-frequency metadata`. Disposition: **replaced**. Reason: Pakar delegates construction, anchors the next step to the previous deadline, skips fully expired steps, and zeros idle metadata. Register-double tests exercise late calls, wraparound and idle state. - Omitted/replaced code: `Permissive enqueue of unsupported frequency/zero-duration requests`. Disposition: **replaced**. Reason: Rejecting zero durations and nonzero frequencies outside 20..20000 Hz matches the bounded wire contract and avoids silently scheduling an unplayable request. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. -- Test-code evidence: `tests/tone_player_test.cpp:50 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.setMuted(false)]`. -- Test-code evidence: `tests/tone_player_test.cpp:45 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const auto beforeMute]`. -- Test-code evidence: `tests/pc_contract_test.cpp:115 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentRequest[22]]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/tone_player_test.cpp:51 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.setMuted(false)]`. +- Test-code evidence: `tests/tone_player_test.cpp:46 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const auto beforeMute]`. +- Test-code evidence: `tests/pc_contract_test.cpp:118 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentRequest[22]]`. - Test-code evidence: `desktop/src/board_silent.rs:446 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_silent_preserves_other_settings]`. - Test-code evidence: `mobile/test/board_sound_test.dart:229 [UTF8-LF sha256=e37acd59e38b8dc8696eb190f3018444352128cf0eb13ad59d125cf007cd5a45; anchor=Silent API uses actual AVR ACK/readback]`. - Test-code evidence: `tests/board_silent_test.mjs:9 [UTF8-LF sha256=95f4c3048054666df7673c618f09f57d7e2c37d34f42276b0fcd021037772825; anchor=for (const mode of []`. -- Test-code evidence: `tests/tone_player_test.cpp:167 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=fifteen finite cues]`. +- Test-code evidence: `tests/tone_player_test.cpp:198 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=fifteen finite cues]`. - Test-code evidence: `tests/attention_cues_test.cpp:10 [UTF8-LF sha256=2b6bd229d5c9b9c5bdc233b97b88ca4d3422e34fad2dcb20dbb11db46732b3b0; anchor=Late disconnect cannot replace completion]`. - Scoped advantage: Tested late-loop and wraparound timing without cumulative melody-length drift; initialized default construction; validation at enqueue; mute changes publish immediately; unmute no longer restarts an interrupted note. - Remaining: Physical timing, mute/unmute acoustics and supply/clock validity remain issue #2 gates. @@ -932,13 +1146,13 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `edb2f40cfdfb37d1c08867c121c617beb8f9f53ad2583855ae2218a07eb9fd89`. -- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=78d374b149b483bc8203a0e879c3cf10905d1e9b8ab2a3dfc6becc2d15104746; anchor=bool enqueue]`. -- Target: `firmware/avr/TonePlayer.cpp:70 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=startsAt = stepEndsAt_]`. +- Target: `firmware/avr/TonePlayer.h:19 [UTF8-LF sha256=d8fb0cfc7a73a9c10bc378d85426fda01894c9991dc827ef9ed0cb5a72357c98; anchor=bool enqueue]`. +- Target: `firmware/avr/TonePlayer.cpp:87 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=startsAt = stepEndsAt_]`. - Target: `desktop/src/board_silent.rs:128 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=pub struct Transaction]`. - Target: `desktop/src/ui/preferences.rs:262 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn board_controls(]`. -- Target: `mobile/lib/api.dart:1882 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future boardSound(]`. +- Target: `mobile/lib/api.dart:2109 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future boardSound(]`. - Target: `mobile/lib/board_sound_preferences.dart:10 [UTF8-LF sha256=888e518c1a85ff594b6c4c778f2eb770a4cbcad3a1d53d3defea19b5e226dc5d; anchor=class BoardSoundPreferences]`. -- Target: `firmware/esp/src/WebUi.h:3124 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=async function boardSoundTransaction(]`. +- Target: `firmware/esp/src/WebUi.h:3542 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=async function boardSoundTransaction(]`. - Target: `firmware/avr/BootMelody.cpp:37 [UTF8-LF sha256=fad1505f9cedc196236fc782ae2ec71a3c9fb8784659a1955affdf9e44137247; anchor=case kCueAttention:]`. - Retained: Timer1 OC1A PB1/D9 hardware output without audio ISR; ten-step nonblocking ring queue; silent pauses; begin/stop/mute and active telemetry. - Retained: PCController already uses Timer1 hardware toggling; that is reuse, not a Pakar invention. @@ -947,14 +1161,14 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Retained: The existing Timer1/nonblocking player now also owns a finite 240ms attention cue, additive ID14; cue count is ACK-gated across all clients. Silent/Prog behavior and original melodies remain unchanged. - Omitted/replaced code: `now + step.durationMs; nondelegating default constructor; stale idle active-frequency metadata`. Disposition: **replaced**. Reason: Pakar delegates construction, anchors the next step to the previous deadline, skips fully expired steps, and zeros idle metadata. Register-double tests exercise late calls, wraparound and idle state. - Omitted/replaced code: `Permissive enqueue of unsupported frequency/zero-duration requests`. Disposition: **replaced**. Reason: Rejecting zero durations and nonzero frequencies outside 20..20000 Hz matches the bounded wire contract and avoids silently scheduling an unplayable request. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. -- Test-code evidence: `tests/tone_player_test.cpp:50 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.setMuted(false)]`. -- Test-code evidence: `tests/tone_player_test.cpp:45 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const auto beforeMute]`. -- Test-code evidence: `tests/pc_contract_test.cpp:115 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentRequest[22]]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/tone_player_test.cpp:51 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.setMuted(false)]`. +- Test-code evidence: `tests/tone_player_test.cpp:46 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const auto beforeMute]`. +- Test-code evidence: `tests/pc_contract_test.cpp:118 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentRequest[22]]`. - Test-code evidence: `desktop/src/board_silent.rs:446 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_silent_preserves_other_settings]`. - Test-code evidence: `mobile/test/board_sound_test.dart:229 [UTF8-LF sha256=e37acd59e38b8dc8696eb190f3018444352128cf0eb13ad59d125cf007cd5a45; anchor=Silent API uses actual AVR ACK/readback]`. - Test-code evidence: `tests/board_silent_test.mjs:9 [UTF8-LF sha256=95f4c3048054666df7673c618f09f57d7e2c37d34f42276b0fcd021037772825; anchor=for (const mode of []`. -- Test-code evidence: `tests/tone_player_test.cpp:167 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=fifteen finite cues]`. +- Test-code evidence: `tests/tone_player_test.cpp:198 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=fifteen finite cues]`. - Scoped advantage: Tested late-loop and wraparound timing without cumulative melody-length drift; initialized default construction; validation at enqueue; mute changes publish immediately; unmute no longer restarts an interrupted note. - Remaining: Physical timing, mute/unmute acoustics and supply/clock validity remain issue #2 gates. - Remaining: Focused fixtures/builds are source evidence only. Physical ASUS USB/Wi-Fi, reboot persistence, muted welcome/menu/cue audio and subsequent unmute require the actual AVR; SSH8022 is currently unavailable. Full donor host routing/backend/configuration parity is not established. @@ -963,8 +1177,8 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `728e6808793b9ad82baf19d6e4c98a8fec68bce6b83d0fff0da5c7f31d48b9c4`. -- Target: `mobile/android/app/src/main/AndroidManifest.xml:7 [UTF8-LF sha256=d6b34f69c5fccf910172119ad972a479a8d48a2ba7861d351f79e440d156ca0b; anchor=REQUEST_INSTALL_PACKAGES]`. -- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:39 [UTF8-LF sha256=8988cab4eac7a2f1a831cb0e0ab1179fd7e194030feafaf3ccaef57d3f13fdfe; anchor=appUpdates = AppUpdateChannel]`. +- Target: `mobile/android/app/src/main/AndroidManifest.xml:11 [UTF8-LF sha256=224012484c53db2561f636ffe71fb8bb6a0c80ecb7588a8864260b51264dc710; anchor=REQUEST_INSTALL_PACKAGES]`. +- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:41 [UTF8-LF sha256=262271e184d4287fa3e2a6dc8cac9cae7f5cb0cdf7e2b143ecfaa4288bdfd0cf; anchor=appUpdates = AppUpdateChannel]`. - Retained: Actual REQUEST_INSTALL_PACKAGES and nonexported installer result receiver registration adapted to Pakar application identity. Existing app package and launcher artwork remain unchanged. - Omitted/replaced code: `Remaining generic donor/platform acceptance outside this bounded update slice`. Disposition: **pending**. Reason: Android foreground services/background uptime, split APKs and signing-key rotation, direct Android USB-host firmware programming, generic host RPC parity and actual Android/LAN/TLS/ESP/AVR physical acceptance remain separate. Exact self-package signer sets and newer version codes fail closed; no silent installer or uncertain programming replay. - Test-code evidence: `mobile/test/app_updates_test.dart:190 [UTF8-LF sha256=1aabfe844bf8cb7e159fc7adfe3a061aa8cee02e14391f21b4f858ff7612bdfe; anchor=native bridge]`. @@ -975,23 +1189,30 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `b4b829b34dfd99d1cadcd05b4d15b23e1277d6d8ffa82090f2abfd4843d48c5c`. - Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/AppUpdateDiscoveryChannel.kt:16 [UTF8-LF sha256=671c6d58afc2c594f4bd7df89cbd7d98b233b43c7be14fc5697109924bdd4678; anchor=class AppUpdateDiscoveryChannel]`. +- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/HostWifiChannel.kt:25 [UTF8-LF sha256=f13356e25411713c0075585d3e3618452773d0f19d909e7360173302371b735e; anchor=class HostWifiChannel]`. +- Target: `mobile/lib/host_wifi.dart:11 [UTF8-LF sha256=0d2d6572a327b800e5ae5f34f670e427ed2ef5c216ba8b28e4d0471dcbe3268a; anchor=class HostWifi]`. +- Target: `desktop/src/platform/host_wifi.rs:102 [UTF8-LF sha256=acbb40d2fa47e1c098c656dfe118e2aaf907036debeb9c00c016953139e446b4; anchor=pub fn join]`. +- Target: `desktop/src/ui/host_wifi.rs:21 [UTF8-LF sha256=b6d400eb8e1ddbfb59138452f61081eddd8d561629dc61a1ccc2c1af4613de08; anchor=pub fn draw]`. - Target: `mobile/lib/app_update_discovery.dart:58 [UTF8-LF sha256=ac6157940d99d94c21a32e7baebe1e7d05d868fb432016f63cd2efad74fd2fc8; anchor=verifyAppUpdatePeer]`. -- Retained: Actual Android NSD browse/resolve/registration and multicast lock lifecycle adapted to app-package-only service. Dart uses bounded SSDP/MNDP messages plus exact package manifests; raw advertisements never grant install or board control. +- Retained: Actual Android NSD browse/resolve/registration and multicast lock lifecycle adapted to app-package-only service. Dart uses bounded SSDP/MNDP messages plus exact package manifests; raw advertisements never grant install or board control. Issue #31 reread the complete actual DiscoveryChannel and reused main-loop callback ownership, stale-callback rejection and close-time retirement in HostWifiChannel. Donor has no host Wi-Fi join implementation: Android NetworkSpecifier/save-network consent and Windows Native Wi-Fi profile calls are new platform integration, not a claimed donor import. Windows UI reuses existing fixed-geometry dialog/dropdown controls; no implicit join on render/discovery, no passwords in app preferences and no claim that OS acceptance verifies a board. - Omitted/replaced code: `Remaining generic donor/platform acceptance outside this bounded update slice`. Disposition: **pending**. Reason: Android foreground services/background uptime, split APKs and signing-key rotation, direct Android USB-host firmware programming, generic host RPC parity and actual Android/LAN/TLS/ESP/AVR physical acceptance remain separate. Exact self-package signer sets and newer version codes fail closed; no silent installer or uncertain programming replay. - Test-code evidence: `mobile/test/app_update_discovery_test.dart:37 [UTF8-LF sha256=202d5265b03db20e13768806bf617a944ddd3a70f7241d17e559c3c319bdede9; anchor=Actual manifest GET]`. +- Test-code evidence: `mobile/test/host_wifi_test.dart:9 [UTF8-LF sha256=89de0319a0a89f2f8fcf72eecdf642a93db117de9021c9198f0e619f87e9eb88; anchor=network hints use reported SSIDs]`. +- Test-code evidence: `desktop/src/ui/host_wifi.rs:108 [UTF8-LF sha256=b6d400eb8e1ddbfb59138452f61081eddd8d561629dc61a1ccc2c1af4613de08; anchor=fn rendering_does_not_join_or_scan]`. - Remaining: Issue #7/#2/#3: actual installed Android peer transfer/OS consent/remote endpoint/board flash and human acceptance; source tests and staged APK are not deployment. ### PCController: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt` Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `5bf90a090b5975f5dc3b7e09ed47ba09e9dad8bf812515902b24cacc35e9a618`. -- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:31 [UTF8-LF sha256=8988cab4eac7a2f1a831cb0e0ab1179fd7e194030feafaf3ccaef57d3f13fdfe; anchor=private val firmwareMaximum = 0x330000 - 1]`. +- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:32 [UTF8-LF sha256=262271e184d4287fa3e2a6dc8cac9cae7f5cb0cdf7e2b143ecfaa4288bdfd0cf; anchor=private val firmwareMaximum = 0x330000 - 1]`. - Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UsbControllerChannel.kt:22 [UTF8-LF sha256=9261161c45322fdd1b277b4deb13dbefd10a31d112e610e04490314f83e0af57; anchor=class UsbControllerChannel]`. - Target: `mobile/lib/documents.dart:19 [UTF8-LF sha256=9b447b248f21e855c2481e28c6c31e66969af5ab837fd32e4d57eb76164b9ff5; anchor=class AndroidDocuments]`. - Target: `mobile/lib/firmware_image.dart:8 [UTF8-LF sha256=274b96b95c959594b8720f40d4998dc6e307ed8fe31e82093c19b581ea4a0654; anchor=abstract final class FirmwareImagePolicy]`. - Retained: Actual canonical 8269-byte MainActivity read completely through EOF by the mobile owner and evidence owner. Retains real ACTION_OPEN_DOCUMENT/ACTION_CREATE_DOCUMENT, single selection, bounded executor-based input reads, cancellation, one outstanding document operation, one-use random destination token consumed before write, explicit abandon without deletion, finite byte export and close-time token/pending cleanup. Pakar package/channel names replace PCController identity, not its document ownership contract. - Retained: USB-OTG review reread the complete canonical MainActivity through EOF at the recorded donor hash. Reuse its owned platform-channel/executor/close-time retirement pattern for UsbControllerChannel. No Android USB driver exists in the inventoried donor: Android UsbManager is a new bounded CDC ACM adapter, not a claimed wholesale donor import. Native USB enumeration, permission and external-power safety remain physical acceptance gates. - Retained: The obsolete generic2097152-byte intake/export ceiling is replaced by the same exclusive0x330000 S3 application-slot ceiling used by Dart firmware admission. Native SAF remains only a bounded byte/document provider: selected-board chip/hash-header/capacity/SHA checks happen in the firmware owner before upload. A selected or exported document never automatically programs ESP or AVR. +- Retained: 2026-10-27 minigame mobile gate reread the complete canonical donor and Pakar MainActivity through EOF. The donor remains clean at b0111ae0c80dc34149ea1044de7dd5488920301b with original-byte SHA256 5bf90a090b5975f5dc3b7e09ed47ba09e9dad8bf812515902b24cacc35e9a618; restored that source identity after it had been replaced by the identically named target hash. Existing bounded SAF/token/lifecycle adaptation and pending automation/preferences/upgrade omissions remain unchanged; this narrow rereview makes no new Android runtime or broad parity claim. - Omitted/replaced code: `AutomationBridge.channel, automation MethodChannel and cleanup`. Disposition: **pending**. Reason: The donor's generic Android automation bridge is not part of this SAF/image-limit change. App-to-app remote commands and intent authorization require their actual implementation/dependency review; no document channel or BLE integration is claimed to substitute for it. - Omitted/replaced code: `PreferencesChannel and preferences.launch from onNewIntent`. Disposition: **pending**. Reason: Pakar has its own preferences UI but does not yet import this donor's OS preference-launch/new-intent lifecycle. Opening SAF or retaining Flutter state does not implement that useful Android contract. - Omitted/replaced code: `UpgradeChannel construction/lifecycle and whole-activity discovery/tone/app-update parity`. Disposition: **pending**. Reason: Pakar separately owns AppUpdateChannel, peer-discovery, BLE and tone channels; this row certifies only the inspected SAF adaptation, not full donor upgrade/automation/discovery/mirroring equivalence or provider/OS behavior. Keep their owning rows and remaining acceptance rather than infer parity from class names. @@ -1032,7 +1253,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `7c4c3a679ede250bb44effc96ccaf8a33a49f15e3ec8a1e1f5dfca082d64c72f`. - Target: `mobile/lib/connection_preferences.dart:56 [UTF8-LF sha256=9b4b4d787f7169a31f09717118c63ec7f88476df0568a84eb04909a0bd547d54; anchor=class ConnectionPreferences]`. -- Target: `mobile/lib/main.dart:887 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=_saveWorkspaces]`. +- Target: `mobile/lib/main.dart:890 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=_saveWorkspaces]`. - Retained: Complete donor read: preserve secure password migration and last explicit connection profile; adapt nonsecret connection draft restoration and explicit Disconnect/transport intent without automatically selecting discovered paths. Pristine settings fields follow async profile loading, edited fields retain the draft. - Omitted/replaced code: `Mandatory/nonempty default password and restoring secret drafts`. Disposition: **replaced**. Reason: Current board authentication is optional. Existing secure credential ownership remains separate; workspace persistence excludes passwords/tokens and does not require credentials before the board does. - Test-code evidence: `mobile/test/workspace_lifecycle_test.dart:13 [UTF8-LF sha256=537941477dab0a2460cf42aab9220a7baf8d1aecf36eb140b21179674985071e; anchor=connection]`. @@ -1043,7 +1264,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `16c5f8ec88c242baa279bc42bdf4c81024f4473eccc62ec8e317267937a9ab2c`. - Target: `mobile/lib/device_settings.dart:222 [UTF8-LF sha256=0c10e5c712bae2b4fe2f706326115ee703f07e426a64a50728c09575efb71e9a; anchor=class DeviceSettingsWorkspace]`. -- Target: `mobile/lib/device_settings_page.dart:18 [UTF8-LF sha256=1d618c036930215ab12bc3a827625eef5819a3969a7663615115a65ba8519c27; anchor=class DeviceSettingsPage]`. +- Target: `mobile/lib/device_settings_page.dart:18 [UTF8-LF sha256=b187bcc1ed640dc8b7052ce03c4f36a840289396f8908dbc77b967190eb9e9ee; anchor=class DeviceSettingsPage]`. - Retained: Complete donor read: adapt actual SettingsDraft.observe saved/value/conflict and owner/epoch/ACK/readback semantics to persisted public-only patches after fresh reads. Preserve original baselines over repeated reconnects so conflicts cannot silently become saveable; secret replacement/clear intent and pending operations are never serialized. - Retained: The full donor was reread at the same recorded hash together with actual native access.rs and firmware AccessConfig/WebAccess. The mobile auth draft now preserves enabled/username baseline conflicts, applies disabled/enabled credential rules, verifies the exact positive ACK and public readback, then hands credentials to the same connection owner. First-wins LED settings navigate to Output, not a missing Table workspace. API lacks compare-and-set; preflight/readback is not atomic multi-client concurrency. - Omitted/replaced code: `Persisting secret fields and uncertain write intents`. Disposition: **replaced**. Reason: Secrets stay in the existing secure/memory-only owners; pending writes are revoked, never automatically replayed after reconnect. @@ -1057,8 +1278,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `077c722018b5849d5d972f228b326151da15cc4238c3cd54052219df4d8d59db`. -- Target: `mobile/lib/devices_page.dart:67 [UTF8-LF sha256=0604a0443c5949d8de936ddb9230a39fb8e06642f9258a18c9b9d63722513533; anchor=List unifiedDevices(]`. -- Target: `mobile/lib/devices_page.dart:413 [UTF8-LF sha256=0604a0443c5949d8de936ddb9230a39fb8e06642f9258a18c9b9d63722513533; anchor=Future connect(DeviceListEntry entry)]`. +- Target: `mobile/lib/devices_page.dart:70 [UTF8-LF sha256=73b22cde59bac6f882fd989e6494a09d3d19b010b0fc8e5ae69e55591eca44c0; anchor=List unifiedDevices(]`. +- Target: `mobile/lib/devices_page.dart:416 [UTF8-LF sha256=73b22cde59bac6f882fd989e6494a09d3d19b010b0fc8e5ae69e55591eca44c0; anchor=Future connect(DeviceListEntry entry)]`. - Target: `mobile/lib/device_registry.dart:115 [UTF8-LF sha256=aa10bd84de1e6bc16e5c9d455db6ef3e97251276fa6a41184eebeb0bcd99ad3f; anchor=Future save(]`. - Target: `mobile/lib/workspace_widgets.dart:162 [UTF8-LF sha256=26ce0129b1723a7e37fe28e1aa7a4df096b2f7bbcbeaffcde244139a42d9b6a1; anchor=class WorkspaceSection]`. - Target: `mobile/lib/app_theme.dart:16 [UTF8-LF sha256=ba590e394b1ff6bf6b23d6921804c1f64b1908814c41ec9e7112d91a57854c58; anchor=ThemeData studioTheme({Map palette]`. @@ -1079,9 +1300,9 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Target: `mobile/lib/discovery.dart:430 [UTF8-LF sha256=8cb3956f80a50c5be2c134829bc7d323a3f4c58fbbcf1ea5d5e1aaa10ac7fbba; anchor=Future verifyDiscoveredDevice(]`. - Target: `mobile/lib/discovery.dart:46 [UTF8-LF sha256=8cb3956f80a50c5be2c134829bc7d323a3f4c58fbbcf1ea5d5e1aaa10ac7fbba; anchor=bool sameBoard(DiscoveredDevice other)]`. -- Target: `mobile/lib/devices_page.dart:56 [UTF8-LF sha256=0604a0443c5949d8de936ddb9230a39fb8e06642f9258a18c9b9d63722513533; anchor=String? preferredOrigin(ConnectionMethod method)]`. +- Target: `mobile/lib/devices_page.dart:59 [UTF8-LF sha256=73b22cde59bac6f882fd989e6494a09d3d19b010b0fc8e5ae69e55591eca44c0; anchor=String? preferredOrigin(ConnectionMethod method)]`. - Target: `mobile/lib/discovery_datagrams.dart:8 [UTF8-LF sha256=4a3a65c3e5227dbce188f3576dd9cee6a386594939cb3dac03ab52cacfb033a9; anchor=Future collectDiscoveryDatagrams({]`. -- Target: `mobile/lib/api.dart:2843 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future> _discoverSsdp()]`. +- Target: `mobile/lib/api.dart:3081 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future> _discoverSsdp()]`. - Retained: Complete actual donor source read. Adapted scan concurrency guard, bounded four-worker public compatibility probes, source-pinned SSDP response locations, duplicate-header rejection, packet/TLV bounds, metadata-only discovery and failure cleanup. Pakar public description.xml plus capability-bearing status replace donor healthz/public-directory endpoints; scanning never sends credentials or mutations. - Retained: Added one session catalog keyed by stable board identity/UDN, normalized MAC, canonical origins and observed IPs rather than endpoint+protocol. Protocols and hostname aliases merge; explicit conflicting board IDs cannot merge on a reused DHCP address. Distinct origins are probed before cross-channel coalescing so an Android DNS failure cannot suppress a working IP advertisement. Failed scans revoke availability without discarding remembered metadata. - Retained: Reread the exact complete donor discovery.dart and resolver.go at b0111ae0 with matching source hashes. Preserve its bounded per-origin public probes while expanding saved/advertised aliases before board coalescing. Keep current-sweep exact-origin observations separate from identity/history: AP, STA and Ethernet IP/scheme/port do not normalize into each other, and aliases do not inherit availability. The route selector retains explicit saved/selected origin through rescans and Connect uses that exact origin only; unsaved default preference chooses a currently observed hostname when available. Existing no-redirect, auth-on-challenge, board/boot checks and no mutation replay remain. @@ -1120,8 +1341,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `mobile/lib/firmware_image.dart:8 [UTF8-LF sha256=274b96b95c959594b8720f40d4998dc6e307ed8fe31e82093c19b581ea4a0654; anchor=abstract final class FirmwareImagePolicy]`. - Target: `mobile/lib/documents.dart:22 [UTF8-LF sha256=9b447b248f21e855c2481e28c6c31e66969af5ab837fd32e4d57eb76164b9ff5; anchor=int maximum = FirmwareImagePolicy.maximumBytes]`. - Target: `mobile/lib/firmware_page.dart:308 [UTF8-LF sha256=c33c2df6dfc69b5f3b69f1a4f77b8feb5497e90ed3458a5e84f1bb2cc3c7d319; anchor=maximum: controller.imageMaximum(esp),]`. -- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:31 [UTF8-LF sha256=8988cab4eac7a2f1a831cb0e0ab1179fd7e194030feafaf3ccaef57d3f13fdfe; anchor=private val firmwareMaximum = 0x330000 - 1]`. -- Target: `mobile/lib/api.dart:290 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Stream get statusUpdates]`. +- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:32 [UTF8-LF sha256=262271e184d4287fa3e2a6dc8cac9cae7f5cb0cdf7e2b143ecfaa4288bdfd0cf; anchor=private val firmwareMaximum = 0x330000 - 1]`. +- Target: `mobile/lib/api.dart:311 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Stream get statusUpdates]`. - Retained: Completely read the current canonical donor. Retain exact full AVR image/Urboot staging, explicit remote stage/apply/job pinning, capability/connection ownership and no uncertain write replay. Extend its actual manual updater with independently verified HTTP(S) file download, upload byte callbacks and raw-length/MD5 OTA headers; progress uses the existing peer event stream. - Retained: FirmwareImagePolicy now owns the platform-specific S3/ESP8266/AVR byte/header/capacity policy used by HTTP intake, selected SAF document, manual upload and firmware export. FirmwareController.imageMaximum/validateImage consume only selected-board observed capacity; free heap/total flash are not fabricated slot sizes. Android's outer document ceiling matches the S3 exclusive slot bound, while AVR staging remains exactly32768 and never automatic programming. - Omitted/replaced code: `Remaining donor mechanisms outside this delivery slice`. Disposition: **pending**. Reason: Direct Android USB-host flashing and a production Android background firmware service remain unsupported, not silently replaced by Wi-Fi. AVR raw staging is not programming. Automatic verified-manifest Apply now requires an explicit persisted exact-board/application policy and a durable consumed-digest receipt; this does not provide an unguarded raw-image auto-program path. @@ -1161,13 +1382,13 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `mobile/lib/controller_dialog.dart:5 [UTF8-LF sha256=055a3896bee6098534ff9b9271c2e753effb44ace14e614bc4f69e53b9452d0f; anchor=class ControllerDialog]`. - Target: `mobile/lib/cell_pixel_dialog.dart:20 [UTF8-LF sha256=766e5f3ffbd514241080ea122f843f2c1b1e571872313030449a21c248e58f58; anchor=class CellPixelDialog]`. -- Target: `mobile/lib/pixel_correction_menu.dart:8 [UTF8-LF sha256=bacddb85a646905ab2430122724c5222b5a6513161f5e9ca13609e03e0ec5a8d; anchor=showPixelCorrectionMenu(]`. +- Target: `mobile/lib/pixel_correction_menu.dart:10 [UTF8-LF sha256=2afbffed4bb273f15b2afe8043f66c26a8d6b1ce605b655004879e3db4d66129; anchor=showPixelCorrectionMenu(]`. - Retained: Read the complete canonical donor; imported its constrained keyboard-safe phone sheet/tablet dialog, shared icon header, close guard, scrollable body and wrapped footer into controller_dialog.dart. The physical pixel inspector uses the actual component; selecting a pixel does not issue a command. - Retained: The pixel long-press correction menu uses the actual imported ControllerDialog with adaptive phone/tablet constraints, semantic operation icons and explicit zero-based output-slot intent. Closing or choosing an operation does not issue a board command; the original peer remains fenced through the selected Corrections tab. - Retained: Reread the full donor for the canonical Web correction editor. Adapted fixed actions before a bounded scrolling body, following the actual Flutter correction page too; keyboard-focusable named rules region and short-height fallback retain the existing DOM/theme and peer owner. Phone-width End-key scrolling reached the sixth local rule with actions visible. Browser confirmation stalled during disposable-draft cleanup; no board Save was issued. - Omitted/replaced code: `Donor application-specific callers`. Disposition: **not-applicable**. Reason: The shared dialog is imported; unrelated device/output dialogs remain owned by their existing Pakar panels, not copied as duplicate application state. - Test-code evidence: `mobile/test/cell_pixel_dialog_test.dart:69 [UTF8-LF sha256=aa35e10c944ed7694024f7582a4c1274996b02ef79ed817ca5b006288b2ae35c; anchor=Physical numbers follow the map]`. -- Test-code evidence: `mobile/test/pixel_correction_menu_test.dart:14 [UTF8-LF sha256=c26fbe9820c431b97d4a150452827e50e9d7df25def5846881a07fe4f0ea5a79; anchor=Actual diagnostic long press opens exact correction draft without sending]`. +- Test-code evidence: `mobile/test/pixel_correction_menu_test.dart:58 [UTF8-LF sha256=f56a8538df3040e5788c2eaa0e07443bf61d53dd693b45652339374513231662; anchor=Actual diagnostic long press opens exact correction draft without sending]`. - Remaining: Tablet install and physical output acceptance recorded separately in the workbench receipt. ### PCController: `mobile/lib/unified/effect_editor.dart` @@ -1176,15 +1397,15 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `libraries/BoardCore/src/KeyPresentation.h:21 [UTF8-LF sha256=a803f0bbbf415be558bc0a89bce0ba1633188f87d6a614f0242c9f25a5be489f; anchor=void inheritIlluminationMotion(]`. - Target: `mobile/lib/pixel_diagnostic_page.dart:407 [UTF8-LF sha256=804a63cddd3a7fa6d15cdf63d12eab70200fcd80bd527a1f93b715d2db6ae824; anchor=title: 'Diagnostic timing']`. -- Target: `mobile/lib/pixel_diagnostic.dart:291 [UTF8-LF sha256=c5143cb176678151bc81273fbd7ebe7d666d9a145f400efc65ac1a8e7ecb817c; anchor=class PixelProbe]`. -- Target: `desktop/src/pixel_groups.rs:130 [UTF8-LF sha256=1af67b1de715a81756bf6e01ca298c8cce569171f161c97c11599763eadbd489; anchor=pub struct Editor]`. -- Target: `desktop/src/ui/panel_details.rs:114 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=pub fn physical_groups]`. -- Target: `desktop/src/pixel_corrections.rs:4 [UTF8-LF sha256=b1303032d704a39d11fb222bb539b08a176f2fdcdef60cccb06f62a52195be19; anchor=pub struct Rule]`. +- Target: `mobile/lib/pixel_diagnostic.dart:301 [UTF8-LF sha256=f454f8dd79f7ac69a63f8dd16a63ef6e6d34fd51dbfcd16113aed3a278f9ec75; anchor=class PixelProbe]`. +- Target: `desktop/src/pixel_groups.rs:130 [UTF8-LF sha256=5130735e5de707f2b3f10c4fc8562708c8c5daea880ebebdcda0bd4565a19389; anchor=pub struct Editor]`. +- Target: `desktop/src/ui/panel_details.rs:114 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=pub fn physical_groups]`. +- Target: `desktop/src/pixel_corrections.rs:44 [UTF8-LF sha256=e94a5da1dc425966b34646b8dccc55380cd7a8f4939da066154539e326aa563b; anchor=pub struct Rule]`. - Target: `mobile/lib/pixel_tools_navigation.dart:8 [UTF8-LF sha256=bbc13b4422def156b5ed0b8df7211f465fd9a54fcfaadf2b30f7a293784d3f8a; anchor=class PixelToolsNavigation]`. -- Target: `mobile/lib/pixel_corrections_page.dart:539 [UTF8-LF sha256=392cc722fcfcf4731c6ef8880d31d26ba52a4a926be32f8b415669e42a9513a0; anchor=Future requestLeave()]`. -- Target: `mobile/lib/main.dart:1302 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=final correctionPeer = (]`. -- Target: `mobile/lib/pixel_correction_address.dart:22 [UTF8-LF sha256=d746e76f0a761ff2db83a35bd182ee7ba65edb67ad3712a4a6015e91cd242aed; anchor=String pixelCorrectionAddress({]`. -- Target: `mobile/lib/pixel_corrections_editor.dart:6 [UTF8-LF sha256=8f2332519d2fbf57767e5999201172d1ffcd1be9010faf4b1bd2289093d5f80c; anchor=class PixelCorrectionRuleIntent]`. +- Target: `mobile/lib/pixel_corrections_page.dart:594 [UTF8-LF sha256=731bf8e23114e7e3297efb2837244158633a79a49695e17e35276f4c592d5cb5; anchor=Future requestLeave()]`. +- Target: `mobile/lib/main.dart:1311 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=final correctionPeer = (]`. +- Target: `mobile/lib/pixel_correction_address.dart:47 [UTF8-LF sha256=55d3ac53a28ee0eeb4b262b5ac0f74052383f613f10ccbfa2c4f9518f2ae10c6; anchor=String pixelCorrectionAddress({]`. +- Target: `mobile/lib/pixel_corrections_editor.dart:6 [UTF8-LF sha256=7b307453c53c084f22bb5e3845b43ba56e34a83633447b36041c4b1facad64c5; anchor=class PixelCorrectionRuleIntent]`. - Retained: Diagnostic owner read the complete donor. Adapted numberDrafts/numberError separation, bounded typed values and responsive LayoutBuilder/Wrap into diagnostic timing controls. ESP renders blink/breathe/rainbow; Flutter never streams synthetic animation frames or reports a draft as applied. - Retained: Native physical-group configuration adapts the donor's local numeric drafts, explicit commit and responsive parameter layout through existing Pealayer-derived dialog/action controls. Stale peer/layout drafts and unverified write outcomes cannot masquerade as applied values; unsaved drafts participate in native lifecycle guards. - Retained: Native correction safety rereads the complete donor effect editor and retains its typed draft versus applied separation. Ordered rules now round-trip the board's enabled flag with only absent legacy flags defaulting true; capability-gated row toggles and bounded integer pixel/offset controls modify local drafts only. Reordering preserves disabled flags and the board-owned source mapping. No save or actuation occurs on paint. @@ -1194,13 +1415,13 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `PCController scene-specific effect playback and AVR pixel limits`. Disposition: **not-applicable**. Reason: Pakar uses its board-owned shared LED scene engine and current board pixel count; donor output limits and business scenes would regress the 480-pixel table. - Test-code evidence: `mobile/test/pixel_diagnostic_advanced_test.dart:14 [UTF8-LF sha256=fd7fd75adf3f6f4957c871647bab1c972a86d251c0797c804d145f8ad979a36f; anchor=Advanced probe is bounded]`. - Test-code evidence: `mobile/test/pixel_diagnostic_advanced_command_test.dart:13 [UTF8-LF sha256=cdd7611993e2ed3ff4a487881b549c0f019989e9e9bdae2a193d90561d291322; anchor=void main()]`. -- Test-code evidence: `desktop/src/pixel_groups.rs:149 [UTF8-LF sha256=1af67b1de715a81756bf6e01ca298c8cce569171f161c97c11599763eadbd489; anchor=fn pixel_groups_validate_physical_offsets_capacity_and_revision]`. -- Test-code evidence: `desktop/src/pixel_corrections.rs:52 [UTF8-LF sha256=b1303032d704a39d11fb222bb539b08a176f2fdcdef60cccb06f62a52195be19; anchor=fn pixel_corrections_disabled_rules_survive_read_edit_reorder_and_write]`. -- Test-code evidence: `desktop/src/pixel_corrections.rs:68 [UTF8-LF sha256=b1303032d704a39d11fb222bb539b08a176f2fdcdef60cccb06f62a52195be19; anchor=fn pixel_corrections_integer_adjustments_obey_boundaries_without_reenabling]`. -- Test-code evidence: `desktop/src/ui/panel_details.rs:664 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=fn pixel_corrections_toggle_is_capability_gated_and_changes_only_local_rule]`. -- Test-code evidence: `mobile/test/pixel_corrections_exit_test.dart:227 [UTF8-LF sha256=f43401685bdd76d93e5374f208fd9ca13dee7ffefaddb334539ac50f15706aaf; anchor=Save on exit awaits persistent readback and blocks repeated Back]`. -- Test-code evidence: `mobile/test/pixel_corrections_exit_test.dart:323 [UTF8-LF sha256=f43401685bdd76d93e5374f208fd9ca13dee7ffefaddb334539ac50f15706aaf; anchor=row labels distinguish wire addresses from prior-rule raw sources and custom spans]`. -- Test-code evidence: `mobile/test/pixel_correction_intent_test.dart:58 [UTF8-LF sha256=28b5254bddb0ddd21c78e9c97152eaa8928e60a53a030cb8afc011b0180b9088; anchor=incoming rule waits for verified load and creates only a selected local draft]`. +- Test-code evidence: `desktop/src/pixel_groups.rs:150 [UTF8-LF sha256=5130735e5de707f2b3f10c4fc8562708c8c5daea880ebebdcda0bd4565a19389; anchor=fn pixel_groups_validate_physical_offsets_capacity_and_revision]`. +- Test-code evidence: `desktop/src/pixel_corrections.rs:949 [UTF8-LF sha256=e94a5da1dc425966b34646b8dccc55380cd7a8f4939da066154539e326aa563b; anchor=fn pixel_corrections_disabled_rules_survive_read_edit_reorder_and_write]`. +- Test-code evidence: `desktop/src/pixel_corrections.rs:976 [UTF8-LF sha256=e94a5da1dc425966b34646b8dccc55380cd7a8f4939da066154539e326aa563b; anchor=fn pixel_corrections_integer_adjustments_obey_boundaries_without_reenabling]`. +- Test-code evidence: `desktop/src/ui/panel_details.rs:682 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=fn pixel_corrections_toggle_is_capability_gated_and_changes_only_local_rule]`. +- Test-code evidence: `mobile/test/pixel_corrections_exit_test.dart:228 [UTF8-LF sha256=7f49a85b05a3f52b7ec186ef25d848b51fc64fa0917a7a246be9295b3a49095e; anchor=Save on exit awaits persistent readback and blocks repeated Back]`. +- Test-code evidence: `mobile/test/pixel_corrections_exit_test.dart:324 [UTF8-LF sha256=7f49a85b05a3f52b7ec186ef25d848b51fc64fa0917a7a246be9295b3a49095e; anchor=row labels distinguish wire addresses from prior-rule raw sources and custom spans]`. +- Test-code evidence: `mobile/test/pixel_correction_intent_test.dart:60 [UTF8-LF sha256=3dbc159703d2defaa692505675fdba20efbd14ec4692707a4c965221ef576d8b; anchor=incoming rule waits for verified load and creates only a selected local draft]`. - Test-code evidence: `mobile/test/pixel_diagnostic_layout_test.dart:84 [UTF8-LF sha256=047e932f5ad0373a159f31cee2be595b1a543e5a3e41a877075d67dec29d065d; anchor=void main()]`. - Remaining: Physical LED effect and timing acceptance remains separate from model/widget/transport tests. @@ -1220,8 +1441,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `fb4552dba17b3ec1bd999b6f43a05c2d85838fc490da4caec4d0b5316e5f9239`. -- Target: `mobile/lib/pixel_diagnostic.dart:120 [UTF8-LF sha256=c5143cb176678151bc81273fbd7ebe7d666d9a145f400efc65ac1a8e7ecb817c; anchor=class PixelDiagnosticWorkspace]`. -- Target: `mobile/lib/main.dart:887 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=_saveWorkspaces]`. +- Target: `mobile/lib/pixel_diagnostic.dart:120 [UTF8-LF sha256=f454f8dd79f7ac69a63f8dd16a63ef6e6d34fd51dbfcd16113aed3a278f9ec75; anchor=class PixelDiagnosticWorkspace]`. +- Target: `mobile/lib/main.dart:890 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=_saveWorkspaces]`. - Retained: Complete donor read: adapt bounded enum/preferences restore and separate phone mirroring options into the existing ordered snapshot writer. Preserve visual/volume/follow-mute preferences without restarting PCM sessions, tones or commands. - Retained: Reread the complete same-hash donor preferences load/save/change notification. Cold-start repair separates observation demand from preference persistence: binding the board workspace immediately restores its saved LED preview subscription; repeated status does not write preferences. Existing coalesced writer and per-board user choices remain authoritative. - Omitted/replaced code: `Per-keystroke sequential preference writes and PC-only branding`. Disposition: **replaced**. Reason: Use one content-equal coalesced writer, preserving the requested low-write policy and Pakar identity. @@ -1234,20 +1455,20 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `eda6596550cfb72833b1fddcdfca660ced88f2e7da3af0ee63ce7d0e7c3d55eb`. -- Target: `mobile/lib/pixel_corrections_editor.dart:111 [UTF8-LF sha256=8f2332519d2fbf57767e5999201172d1ffcd1be9010faf4b1bd2289093d5f80c; anchor=class PixelCorrectionNumberControl]`. -- Target: `mobile/lib/pixel_corrections_page.dart:18 [UTF8-LF sha256=392cc722fcfcf4731c6ef8880d31d26ba52a4a926be32f8b415669e42a9513a0; anchor=class PixelCorrectionsPage]`. +- Target: `mobile/lib/pixel_corrections_editor.dart:115 [UTF8-LF sha256=7b307453c53c084f22bb5e3845b43ba56e34a83633447b36041c4b1facad64c5; anchor=class PixelCorrectionNumberControl]`. +- Target: `mobile/lib/pixel_corrections_page.dart:18 [UTF8-LF sha256=731bf8e23114e7e3297efb2837244158633a79a49695e17e35276f4c592d5cb5; anchor=class PixelCorrectionsPage]`. - Target: `mobile/lib/pixel_groups_page.dart:16 [UTF8-LF sha256=f8ca0b6ce00982ffe4fca9dbaeafcaf38fa774e17a8f2c71b5ec4317fd8deb06; anchor=class PixelGroupsPage]`. -- Target: `assets/web/feature-workspaces.js:22 [UTF8-LF sha256=e3ee781bcd00a2f84a06f893fa224244b8d555b7b9d956edc5cfe30588aa17c0; anchor=function correctionsMatch(]`. +- Target: `assets/web/feature-workspaces.js:22 [UTF8-LF sha256=93f24fd4008ef69eb247dd02d084a531a04b1dffaa86d6b651df43b5b6c3f33a; anchor=function correctionsMatch(]`. - Target: `assets/web/correction-preview.js:7 [UTF8-LF sha256=55aa374ed73c57a940a3b9fa65207250b70a306eb7982ecc68c0c23f10f1e72d; anchor=function createPixelCorrectionPreview(]`. -- Target: `mobile/lib/pixel_correction_preview.dart:14 [UTF8-LF sha256=16f75de8eacb1824b2b79f331e69697041a08c5f95b3a2102765f53ce314e8bd; anchor=class PixelCorrectionPreview]`. +- Target: `mobile/lib/pixel_correction_preview.dart:14 [UTF8-LF sha256=ab3c44bdf9bf29075e4fb825b9e2cf538097652e53582be12bd663e84c3cbb6d; anchor=class PixelCorrectionPreview]`. - Retained: Correction-editor owner read the complete donor. Adapted owned numeric drafts versus authoritative board state, invalid input retention and explicit commit. Live correction preview is a separate bounded board-owned lease; only Save writes persistent corrections. - Retained: Per-group count editor reuses the imported ControllerDialog and existing bounded numeric controls. Counts are local drafts with cumulative physical-address preview; explicit save uses same-peer revision fencing and effective-count readback, including normalized no-op saves. Invalid advertised layouts fail closed rather than silently using six/twelve. - Retained: The Web correction editor follows the actual Flutter correction-row enabled flag and the reread donor's local-edit/explicit-commit split. Per-rule checkboxes modify drafts only, disabled flags survive reordering and full saves, missing flags alone default true, malformed booleans are rejected, and exact saved readback compares enabled alongside kind/address/offset. Existing peer, capability and revision ownership remains shared. - Retained: Reread the complete actual PCController pwm_control at the unchanged inventoried hash and the complete existing Flutter pixel_correction_preview.dart. Adapted Flutter's explicit-only RAM lease, 280ms edit coalescing, exact source/base/owner readback, latency-adjusted renewal/expiry, original-route cleanup and no uncertain replay into a staged transport-injected Web controller. It reuses the canonical correction projection and owns no socket, renderer or persistence. UI integration, transport wiring and deployed behavior are not claimed. - Omitted/replaced code: `PWM channels, duty-cycle write paths and output-specific labels`. Disposition: **not-applicable**. Reason: Pixel correction rules are ordered output-address mappings, not AVR PWM outputs; those hardware operations cannot be reused for this editor. -- Test-code evidence: `mobile/test/pixel_corrections_editor_test.dart:143 [UTF8-LF sha256=b54dabf7b3b719fa4df58191a057d9e42058ae335b3a75037b6e8bab7f766370; anchor=void main()]`. +- Test-code evidence: `mobile/test/pixel_corrections_editor_test.dart:145 [UTF8-LF sha256=ab953eb195c5f8ba3c086666c2f5a7444bccd7d2fb03433c2434d412a8aedeb4; anchor=void main()]`. - Test-code evidence: `mobile/test/pixel_groups_test.dart:102 [UTF8-LF sha256=94341dbc90467d273f5ad3f967a8d38e29738bec3c9509c9742cc25a18d34180; anchor=void main()]`. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:187 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Disabled state follows a reordered rule]`. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:205 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Disabled state follows a reordered rule]`. - Test-code evidence: `tests/web_correction_preview_test.mjs:24 [UTF8-LF sha256=86d135e460c8406eed4d031e3e97327b0d18a5219ea0d3237fea72876d2b6d32; anchor=No automatic preview or timers before explicit start]`. - Remaining: Network correction management is implemented; Flutter USB/BLE correction-editor configuration is not claimed. - Remaining: The staged Web lease controller is not embedded or UI-wired. Explicit capability-gated controls, pinned shared transport/context authorization, foreground/panel/save/reload/disposal lifecycle and rendered acceptance remain open. Firmware currently has fenced HTTP/USB preview handlers but no named WebSocket preview command; do not invent a wire command or claim WebSocket preview support. No production lease or output acceptance from clock/transport doubles. @@ -1260,7 +1481,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `assets/themes/browser-appearance.js:5 [UTF8-LF sha256=056ef41691b92dacbcd7c75b11ff38a2e492093e769aab26dc2577785994de54; anchor=const appearanceDefaults=]`. - Target: `mobile/lib/cell_controls.dart:53 [UTF8-LF sha256=02671bd9d09be5620be54c896172f2bc46eabf78e26c49bf710293558d3ec567; anchor=ButtonStyle cellColorActionStyle(]`. - Target: `mobile/lib/semantic_cells.dart:23 [UTF8-LF sha256=3ad39e619348a04af4cd6f23efeff6421a81ec90eb53f797b9769209438febb7; anchor=bool selectionHasCellState(]`. -- Target: `mobile/lib/main.dart:2358 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=selectionHasCellState(status!.json, selection, value)]`. +- Target: `mobile/lib/main.dart:2379 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=selectionHasCellState(status!.json, selection, value)]`. - Retained: Fully read the actual 51-line relayActionStyle implementation through EOF and verified current donor bytes against the inventory. Adapted its semantic action tone independent of branding, luminance-based black/white contrast selection, WidgetState-resolved enabled/disabled foreground and background, and transparent filled-action borders into cellColorActionStyle. CellColorAction uses that style through FilledButton.icon; public constructors and command/state semantics remain unchanged. - Retained: The target adds a subtle opaque surface blend for board-defined RGB colors, compact 36px painted controls with padded 48px minimum touch targets, 140ms state transitions, distinct hover/focus/pressed overlays, reserved one-pixel geometry, no idle/disabled outline, and a restrained focus edge. CellOption retains its existing FilterChip behavior while adopting tonal fills, smaller icon/type scale, transparent unselected edges and a muted selected semantic edge. - Retained: Web presentation checkpoint reread the complete actual relay_style.dart from canonical PCController at the same inventory revision/hash. Adapt semantic success/danger identity, contrasting text, stable outline and disabled distinction to existing CSS controls and state-caption data attributes; no Flutter widget runtime or game-state business logic is duplicated. Bright green uses dark high-contrast text, light-theme green uses a darker semantic tone, and explicit saved accents remain authoritative while the default uses shared green branding. Normal/hover/active tonal treatments use fixed border geometry and reduced-motion-safe controls. @@ -1278,22 +1499,22 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `5263044d7abac3865b6dcc8887fa6c11ad9a1c89b8c4e2374ffcd3bcc72014c5`. -- Target: `mobile/lib/api.dart:2020 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future _readMapping()]`. -- Target: `mobile/lib/pixel_corrections_page.dart:306 [UTF8-LF sha256=392cc722fcfcf4731c6ef8880d31d26ba52a4a926be32f8b415669e42a9513a0; anchor=Future _hydrateAddressMapping]`. -- Target: `mobile/lib/pixel_correction_address.dart:6 [UTF8-LF sha256=d746e76f0a761ff2db83a35bd182ee7ba65edb67ad3712a4a6015e91cd242aed; anchor=bool pixelCorrectionMapMatches]`. -- Target: `mobile/lib/pixel_diagnostic.dart:198 [UTF8-LF sha256=c5143cb176678151bc81273fbd7ebe7d666d9a145f400efc65ac1a8e7ecb817c; anchor=previous.board != board || previous.boot != boot]`. +- Target: `mobile/lib/api.dart:2247 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future _readMapping()]`. +- Target: `mobile/lib/pixel_corrections_page.dart:332 [UTF8-LF sha256=731bf8e23114e7e3297efb2837244158633a79a49695e17e35276f4c592d5cb5; anchor=Future _hydrateAddressMapping]`. +- Target: `mobile/lib/pixel_correction_address.dart:31 [UTF8-LF sha256=55d3ac53a28ee0eeb4b262b5ac0f74052383f613f10ccbfa2c4f9518f2ae10c6; anchor=bool pixelCorrectionMapMatches]`. +- Target: `mobile/lib/pixel_diagnostic.dart:199 [UTF8-LF sha256=f454f8dd79f7ac69a63f8dd16a63ef6e6d34fd51dbfcd16113aed3a278f9ec75; anchor=previous.board != board || previous.boot != boot]`. - Target: `desktop/src/ui/cell_pixels.rs:120 [UTF8-LF sha256=547feb8158f8436ce6b3664dd90fcd59affdcce21dc7f9be273d2945e7424da6; anchor=pub fn observe_at(]`. -- Target: `desktop/src/ui/cell_table.rs:1179 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=pub(super) fn current_pixel_frame(]`. -- Target: `desktop/src/ui/shell.rs:1306 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=value["pixel_preview"]]`. +- Target: `desktop/src/ui/cell_table.rs:1179 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=pub(super) fn current_pixel_frame(]`. +- Target: `desktop/src/ui/shell.rs:1324 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=value["pixel_preview"]]`. - Target: `mobile/lib/pixel_diagnostic_page.dart:159 [UTF8-LF sha256=804a63cddd3a7fa6d15cdf63d12eab70200fcd80bd527a1f93b715d2db6ae824; anchor=final observedOff = observation != null]`. - Target: `mobile/test/pixel_diagnostic_page_test.dart:279 [UTF8-LF sha256=719e05d13cd64b01eb41e87344f27ae9b4862123f1a469e0b6e7e2af78798fbe; anchor=Reopening an all-off observation keeps colour edits as drafts]`. -- Target: `mobile/lib/api.dart:1772 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future status()]`. -- Target: `mobile/lib/panel_inventory.dart:7 [UTF8-LF sha256=3801e7700d83296997a29da253c44f2472e0b12415a25fb9350f4fd762435b2e; anchor=class PanelInventory]`. +- Target: `mobile/lib/api.dart:1999 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future status()]`. +- Target: `mobile/lib/panel_inventory.dart:7 [UTF8-LF sha256=6af5ef136289eec7b3d82ab764a194424d44aa437178531d1a5c9576b2aa3449; anchor=class PanelInventory]`. - Target: `mobile/lib/ble_codec.dart:55 [UTF8-LF sha256=763b72f5db99b3b26fb7ef004807b0cb3cb924ac0b3acf184ac52e1e4f00733c; anchor=class BleAssembler]`. - Target: `mobile/lib/ble_transport.dart:25 [UTF8-LF sha256=1c51f1ee672365f71bed78a358e9522adcb7f26ff92055732abb48c2519b66fb; anchor=class PakarBleTransport]`. - Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/BleControllerChannel.kt:26 [UTF8-LF sha256=a0d1fb5f512da49f784d560c1f2d190adef6dd68c4ec15a5de9acd75be9d27f0; anchor=class BleControllerChannel]`. - Target: `mobile/lib/connection_preferences.dart:56 [UTF8-LF sha256=9b4b4d787f7169a31f09717118c63ec7f88476df0568a84eb04909a0bd547d54; anchor=class ConnectionPreferences]`. -- Target: `mobile/lib/devices_page.dart:89 [UTF8-LF sha256=0604a0443c5949d8de936ddb9230a39fb8e06642f9258a18c9b9d63722513533; anchor=class DevicesPage]`. +- Target: `mobile/lib/devices_page.dart:92 [UTF8-LF sha256=73b22cde59bac6f882fd989e6494a09d3d19b010b0fc8e5ae69e55591eca44c0; anchor=class DevicesPage]`. - Retained: Corrections-label hydration reread the full donor PeerSession at unchanged SHA5263044d, adapting refresh single-flight and generation-owned inventory reads. Pakar mapping responses identify board/boot directly, so a fresh full WS observation fences the map without two unnecessary status reads; identity-omitting responses still require explicit status readback. Partial/foreign identity and changed connection/configuration are rejected. The corrections editor reads the actual map independently of parent startup hydration, keeps local correction drafts untouched, validates exact physical group spans, retries only failed metadata reads at most three times, and invalidates/reloads on configuration edges without per-frame polling. Installed tablet acceptance remains distinct from these model/widget fixtures. - Retained: Actual PeerSession refresh single-flight and reset-before-await socket ownership adapted to shared status reads, per-socket cleanup and retained previously acknowledged layout inventory. Reads retire before and after writes; a pre/during-write result cannot settle post-ACK confirmation. Fresh individual capabilities still own actuation. The pinned http abortable GET path additionally cancels transport/body reads on deadline/retirement instead of retaining the donor Future.timeout-only limitation. - Retained: Failure-driven review rejects older same-boot HTTP replies as superseded rather than returning cached data as a fresh observation; older WS frames cannot reset liveAge or publish into a revoked UI lease. Ordered real-loopback producer regressions retain equal board-clock captures as valid, without inventing a sequence number. Board/application-keyed panel elements retire old application mode/cue shells while retaining the user's per-board workspace; offline melody slots keep bounds and both controls disabled until actual fresh cues return. These are scoped lifecycle adaptations, not complete donor mobile parity. @@ -1306,17 +1527,17 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `Unified media/PWM/relay/RPC/artifact/feed contracts and all donor transport tests`. Disposition: **pending**. Reason: This scoped connection/ownership/retained-layout adaptation does not import the donor's other device domains, numbered compatibility aliases or certify all transports. Remaining useful generic contracts need individual source and hardware review. - Omitted/replaced code: `Bluetooth physical and bulk-file acceptance`. Disposition: **pending**. Reason: The new adapter covers actual advertised JSON routes and does not copy media/PWM/relay or numbered protocol aliases. Android GATT/permissions and S3 RF/heap/coexistence need real-device acceptance. Direct binary firmware/raw streaming and automatic board-feed policy over BLE are not yet advertised; manual pinned remote firmware jobs remain available. OS process death can still require read-only reacquisition; preserving an Activity GATT is not an Android background service guarantee. - Test-code evidence: `mobile/test/correction_mapping_transport_test.dart:50 [UTF8-LF sha256=e1b626ea75ec1672c3821d6aaf924ed9d231c1f4a14eb2a46724e30b00f94e2d; anchor=fresh producer-bound mapping shares one GET without redundant status]`. -- Test-code evidence: `mobile/test/pixel_correction_mapping_test.dart:182 [UTF8-LF sha256=34091a98f4f64a7b7e33706e73050f4cd2f693ce1b064e3f244c8b444145b7f9; anchor=configuration edge reloads names/order without replacing dirty rules]`. +- Test-code evidence: `mobile/test/pixel_correction_mapping_test.dart:183 [UTF8-LF sha256=34dd9dacd4186d39f4b8a00136a03281339692d57fff72a20c56a0cd8cf1893d; anchor=configuration edge reloads names/order without replacing dirty rules]`. - Test-code evidence: `mobile/test/pixel_diagnostic_page_test.dart:153 [UTF8-LF sha256=719e05d13cd64b01eb41e87344f27ae9b4862123f1a469e0b6e7e2af78798fbe; anchor=Expired diagnostics can refresh, expose failure, and retry only the read]`. - Test-code evidence: `mobile/test/command_admission_transport_test.dart:122 [UTF8-LF sha256=2c53969b0ed1f881ebea20b760e58125d6c6a574b6fbb722edc5073d1bec6fb4; anchor=Fresh same-boot WS admission overtakes a stalled background HTTP read]`. - Test-code evidence: `mobile/test/api_test.dart:86 [UTF8-LF sha256=13fe2bd841ff70ffd8b87da264e84fa758885d8413c7f96493211abe17984b72; anchor=A write retires]`. - Test-code evidence: `desktop/src/ui/cell_pixels.rs:371 [UTF8-LF sha256=547feb8158f8436ce6b3664dd90fcd59affdcce21dc7f9be273d2945e7424da6; anchor=fn usb_capture_time_is_not_replaced_by_chunk_completion_time()]`. -- Test-code evidence: `mobile/test/panel_inventory_test.dart:68 [UTF8-LF sha256=da040bcdd993c2a78185e4f5974c2be7dfd2d2447e9b0dbb5c4c98a0febff664; anchor=testWidgets]`. +- Test-code evidence: `mobile/test/panel_inventory_test.dart:43 [UTF8-LF sha256=3a4c90aa132d64ac64bcff4e67d5bcf24c42896e7f468b3930a825acf87d8b7f; anchor=testWidgets]`. - Test-code evidence: `mobile/test/read_deadline_transport_test.dart:71 [UTF8-LF sha256=5a1a7076e101ece8add3c151ca5dbd3d8f81d5bd0302fca3db7da0c6be9cb8d7; anchor=stalled]`. - Test-code evidence: `mobile/test/realtime_transport_test.dart:13 [UTF8-LF sha256=ef8a5a39abe37c40e3393cdde5b3f012dbc5a7adfc2e2cd104571ca04cd5de7e; anchor=Older same-boot WS producer]`. - Test-code evidence: `mobile/test/selection_batch_network_test.dart:228 [UTF8-LF sha256=41eb7d501bf707974913ef8d1813b0e18d841b85266c032778acd15848f09dc3; anchor=offline slots stay stable]`. - Test-code evidence: `mobile/test/ble_transport_test.dart:232 [UTF8-LF sha256=20e59050dfd5ad7b5474cf67841654a4bcb3bcb1e83f6bc701ce10a314c8d154; anchor=Offline BLE status and command use GATT only]`. -- Test-code evidence: `mobile/lib/command_failure.dart:38 [UTF8-LF sha256=df0347031d740fb91c69cf8030f98ff708e4d2cd16411deb79d2e39b95404a2c; anchor=class DeviceCommandRejected]`. +- Test-code evidence: `mobile/lib/command_failure.dart:62 [UTF8-LF sha256=08630bd0a48b7373e27426ca9d59151059c009428c4aa3eb60f7c7f6eb9aadeb; anchor=class DeviceCommandRejected]`. - Test-code evidence: `mobile/test/command_failure_test.dart:124 [UTF8-LF sha256=b6e282c4ee6ef999e7792ac8c4ccf57c7794c62d70cc74cae33cc59dd3dfea46; anchor=Atomic pixel $issue propagates a specific failure]`. - Test-code evidence: `mobile/test/live_pixel_probe_test.dart:15 [UTF8-LF sha256=728143695ef5326fc95e889ff327d5d623dbd836996de051e201a2835fd44d85; anchor=Explicit live 480-pixel command and full-frame readback]`. - Remaining: Issue #10: phone lifecycle/scroll/visual acceptance, all-client contention and additional donor transport contracts remain separate. Loopback cancellation is not Android radio/runtime proof. @@ -1326,23 +1547,31 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `f00ed672c1f72f1a66fb65cff1cfe0a89dea5f7b6222f154db00a7c52c2e1a82`. -- Target: `mobile/lib/workspace_activity_strip.dart:8 [UTF8-LF sha256=fccaa432c6ff25855405fab28b9ebcddd5de82ae967a8c4246354a75ef593924; anchor=class WorkspaceActivityStrip]`. -- Target: `mobile/lib/main.dart:1921 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=: WorkspaceActivityStrip(]`. +- Target: `mobile/lib/workspace_activity_strip.dart:8 [UTF8-LF sha256=20914014fdad132c501736a63214192ae1446070942d2c838c303c7f82182820; anchor=class WorkspaceActivityStrip]`. +- Target: `mobile/lib/main.dart:1942 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=: WorkspaceActivityStrip(]`. - Target: `mobile/lib/workspace_widgets.dart:303 [UTF8-LF sha256=26ce0129b1723a7e37fe28e1aa7a4df096b2f7bbcbeaffcde244139a42d9b6a1; anchor=class WorkspaceActionCard]`. - Target: `mobile/lib/workspace_widgets.dart:270 [UTF8-LF sha256=26ce0129b1723a7e37fe28e1aa7a4df096b2f7bbcbeaffcde244139a42d9b6a1; anchor=class WorkspaceGrid]`. - Target: `mobile/lib/workspace_widgets.dart:394 [UTF8-LF sha256=26ce0129b1723a7e37fe28e1aa7a4df096b2f7bbcbeaffcde244139a42d9b6a1; anchor=class WorkspaceValueRow]`. +- Target: `mobile/lib/key_box_designer.dart:13 [UTF8-LF sha256=b9466eb99f3e6c89f0c678698b657dcdce2864f83c125d7a86f2000b4d338012; anchor=class KeyBoxDesigner]`. +- Target: `mobile/lib/minigame_panel.dart:14 [UTF8-LF sha256=1dff5f107041785a88a2fef722811be5d3b2a5eba5db16c156ed3b207103cfd4; anchor=class MiniGamePanel]`. +- Target: `mobile/lib/signal_indicator.dart:10 [UTF8-LF sha256=e6ecea591a0074be5252d0e2c90e4cd5dccdab6df35be9ae4fa5a18206d5a149; anchor=class SignalIndicator]`. - Retained: Fully read actual ActivityStrip through EOF. Adapted stable GlobalKey targets, post-frame selected activity reveal (180ms after selection), minimum48px touch controls, semantic selected state, tooltip, reserved one-pixel border and horizontal scrolling phone navigation. Pakar uses discovered string IDs rather than fixed donor indices; the current per-board palette replaces donor per-activity hardcoded colors. - Retained: Reread the same complete canonical donor and verified its recorded hash for footer preservation. The original Pakar NavigationBar from a7d69ce's parent is retained for two-to-five destinations whose selected/unselected labels actually fit at current width, safe insets and text scale. More/longer destinations retain the donor overflow strip without hiding pages; selected IDs survive count/width changes and post-frame reveal callbacks coalesce. Wide tablets keep the existing rail, now scrollable for shorter landscape screens. No contextual destination is removed or fabricated by this layout policy. - Retained: The complete 233-line donor was reread at the recorded hash for panel refinement. WorkspaceActionCard adapts actual ActivityStrip Material/InkWell composition, semantic icon background, a reserved border and directional chevron; WorkspaceGrid and WorkspaceValueRow are responsive controller-specific adaptations. No duplicate navigation or command engine is introduced, and the existing wide-navigation gap is not certified by this card work. +- Retained: Reread the complete unchanged donor for keycontroller's Flutter designer. Reuse Pakar's imported workspace header, semantic icon surfaces, badges and menu rows; adapt the donor's fixed border geometry and bounded Flexible text to dual-group live box cards and a compact game control card. Board-owned minigames and older first-wins controls share one designer, including persistent local selection and grab-offset-preserving group movement. Existing acknowledgement, hold-release and peer fencing stay in their command owners. This does not complete unrelated wide-navigation omissions. +- Retained: The pending signal footer uses the same bounded Material/InkWell target and theme roles, with local icon/value/both persistence. Full donor ActivityStrip was reread; constrained Flexible popup labels fix the discovered 13px overflow. Four focused widget tests cover menu interactions, restore, RSSI changes and phone/tablet RTL large-text bounds. No network or board mutation is performed. - Omitted/replaced code: `vertical activity layout and muted/per-activity color treatment`. Disposition: **pending**. Reason: Wide Pakar currently retains its existing NavigationRail. New phone navigation imports the donor horizontal implementation; a separate wide navigation adaptation is still required for full chrome parity. - Omitted/replaced code: `Fixed Live/Channels/Panel/Studio/Devices/Firmware/Events indices`. Disposition: **replaced**. Reason: Pakar's acknowledged contextual pages use stable string IDs, including Table and Output; copying unrelated media/controller pages would invent unavailable controls. - Test-code evidence: `mobile/test/workspace_activity_strip_test.dart:13 [UTF8-LF sha256=e2ebc24ba06255223d41f9c47c1cfa81c74e31825d8b3df76b280c28031f6567; anchor=Reduced phone and portrait-tablet footers retain the original NavigationBar]`. -- Test-code evidence: `mobile/test/output_panel_test.dart:16 [UTF8-LF sha256=d40cd2b5189af50d1e862629cae0facd6169d7151fbccb0325564a368312b4b6; anchor=Phone activity navigation keeps eight panels reachable without squeezing]`. -- Test-code evidence: `mobile/test/status_bar_layout_test.dart:80 [UTF8-LF sha256=11ff7bcfe66ee8bcf0eb7037de48ae49db0275cc2f5e9dd0d40b05e9045bdf22; anchor=Tablet footer is one 48px row without duplicate safe padding]`. -- Test-code evidence: `mobile/test/status_bar_layout_test.dart:145 [UTF8-LF sha256=11ff7bcfe66ee8bcf0eb7037de48ae49db0275cc2f5e9dd0d40b05e9045bdf22; anchor=Compact status still opens complete error details]`. +- Test-code evidence: `mobile/test/output_panel_test.dart:231 [UTF8-LF sha256=b75b25f46b391a84c0400f08361c4c520e2fda547e95f8b9030078f7f281e317; anchor=Phone activity navigation keeps eight panels reachable without squeezing]`. +- Test-code evidence: `mobile/test/status_bar_layout_test.dart:122 [UTF8-LF sha256=52cb9e5aef5b21cb81477077b515c21c5108b075168eb410ce5617ba9614e447; anchor=Tablet footer is one 48px row without duplicate safe padding]`. +- Test-code evidence: `mobile/test/status_bar_layout_test.dart:187 [UTF8-LF sha256=52cb9e5aef5b21cb81477077b515c21c5108b075168eb410ce5617ba9614e447; anchor=Compact status still opens complete error details]`. - Test-code evidence: `mobile/test/panel_refinement_test.dart:555 [UTF8-LF sha256=3bec5e0a800d4732cb99ad1dba61f7c9f8e2150f6ab8429448868e988aff26df; anchor=Panel cards fit narrow wide RTL and enlarged text without clipping]`. - Test-code evidence: `mobile/test/panel_refinement_test.dart:179 [UTF8-LF sha256=3bec5e0a800d4732cb99ad1dba61f7c9f8e2150f6ab8429448868e988aff26df; anchor=Overview cards wrap to columns without command side effects]`. - Test-code evidence: `mobile/test/panel_refinement_test.dart:286 [UTF8-LF sha256=3bec5e0a800d4732cb99ad1dba61f7c9f8e2150f6ab8429448868e988aff26df; anchor=Stop text keeps its slot across active inactive and pending states]`. +- Test-code evidence: `mobile/test/key_box_designer_test.dart:19 [UTF8-LF sha256=66e019e05ac582c6788cd7aaae7b5fec34d616b67cd7f0de13925973c493f366; anchor=Arrange and group drag preserve selection and never trigger IO]`. +- Test-code evidence: `mobile/test/key_box_designer_test.dart:122 [UTF8-LF sha256=66e019e05ac582c6788cd7aaae7b5fec34d616b67cd7f0de13925973c493f366; anchor=Phone large text and tablet game workspace fit without overflows]`. +- Test-code evidence: `mobile/test/signal_indicator_test.dart:110 [UTF8-LF sha256=290678231599bdaf327512bbf6dac90775919c0f49c204213ca1d89981578a51; anchor=Footer stays bounded on phones and tablets with signal and errors]`. - Remaining: Verify deployed phone navigation and selected activity reveal; complete contextual wide navigation and other workspace chrome parity separately. Source widget tests are not installed or physical acceptance. ### PCController: `mobile/test/appearance_layout_test.dart` @@ -1363,15 +1592,15 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `22e8e24b6c40a006d475448a9364398aef26d322e97d71809a1cc1bb102550f9`. -- Target: `mobile/lib/api.dart:1772 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future status()]`. +- Target: `mobile/lib/api.dart:1999 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future status()]`. - Retained: Read complete donor tests. Adapt shared refresh and retained verified inventory expectations into actual API race and widget element/scroll-position tests. Real raw TCP stalls exercise pinned HTTP cancellation separately from widget binding. - Retained: Keep a pending command ID registered through same-ID HTTP recovery so a 950ms delayed WS receipt wins over stalled HTTP without a second intent. Full-status freshness is independent of diagnostic revision and a covered parent route; unchanged genuine status restores controls while the original six-second authority lease remains enforced. - Omitted/replaced code: `Full unified donor runtime/relay/firmware/PWM test matrix`. Disposition: **pending**. Reason: Connection-specific adaptation is not execution/import of every donor test or the corresponding device-domain implementation. - Test-code evidence: `mobile/test/api_test.dart:56 [UTF8-LF sha256=13fe2bd841ff70ffd8b87da264e84fa758885d8413c7f96493211abe17984b72; anchor=Concurrent status consumers]`. - Test-code evidence: `mobile/test/command_admission_transport_test.dart:35 [UTF8-LF sha256=2c53969b0ed1f881ebea20b760e58125d6c6a574b6fbb722edc5073d1bec6fb4; anchor=Late WS receipt settles same-ID HTTP recovery]`. -- Test-code evidence: `mobile/test/command_fallback_transport_test.dart:127 [UTF8-LF sha256=c05c6aaa794202ddde9051e37513062464264ae34bdd83d70f5917a6509238bb; anchor=void main()]`. +- Test-code evidence: `mobile/test/command_fallback_transport_test.dart:131 [UTF8-LF sha256=0d6590e9ef902d4c3ad270d445fa2312549c56022475d484b16ad64c3718b434; anchor=void main()]`. - Test-code evidence: `mobile/test/command_preferences_widget_test.dart:9 [UTF8-LF sha256=d4830d63f8497a2778a3e54a6bb7fce776d9d014a2765081141c92adaecfa7a3; anchor=Command fallback saves offline]`. -- Test-code evidence: `mobile/test/panel_inventory_test.dart:68 [UTF8-LF sha256=da040bcdd993c2a78185e4f5974c2be7dfd2d2447e9b0dbb5c4c98a0febff664; anchor=testWidgets]`. +- Test-code evidence: `mobile/test/panel_inventory_test.dart:43 [UTF8-LF sha256=3a4c90aa132d64ac64bcff4e67d5bcf24c42896e7f468b3930a825acf87d8b7f; anchor=testWidgets]`. - Test-code evidence: `mobile/test/read_deadline_transport_test.dart:71 [UTF8-LF sha256=5a1a7076e101ece8add3c151ca5dbd3d8f81d5bd0302fca3db7da0c6be9cb8d7; anchor=stalled]`. - Test-code evidence: `mobile/test/realtime_transport_test.dart:13 [UTF8-LF sha256=ef8a5a39abe37c40e3393cdde5b3f012dbc5a7adfc2e2cd104571ca04cd5de7e; anchor=Older same-boot WS producer]`. - Test-code evidence: `mobile/test/selection_batch_network_test.dart:228 [UTF8-LF sha256=41eb7d501bf707974913ef8d1813b0e18d841b85266c032778acd15848f09dc3; anchor=offline slots stay stable]`. @@ -1391,30 +1620,32 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `43417181b23cab40b8a58ec26159ca025fb41d5bdbaf52d20a46bfc926fee1b0`. - Target: `libraries/BoardCore/src/CellBatch.h:9 [UTF8-LF sha256=752eb7baf7d4974f60be098c70a17198ce21b6a973401a01a67b41915e648884; anchor=struct CellBatch]`. -- Target: `firmware/esp/src/LedGrid.cpp:70 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=bool LedGrid::setCells(]`. -- Target: `firmware/esp/src/LedGrid.h:78 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=bool setCells(]`. +- Target: `firmware/esp/src/LedGrid.cpp:72 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=bool LedGrid::setCells(]`. +- Target: `firmware/esp/src/LedGrid.h:86 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=bool setCells(]`. - Target: `libraries/BoardCore/src/TableCellMapping.h:8 [UTF8-LF sha256=8a4d0d13b7779d8a54c74bb95cafcd871814423ee42909bcd5ebbd6006213ba0; anchor=constexpr uint8_t logicalForPhysical]`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:203 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=bool pending=false;]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:210 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=bool pending=false;]`. - Target: `firmware/esp32s3/src/LedWire.h:37 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t zeroHigh=12]`. - Target: `firmware/esp/src/LedRenderAdmission.h:7 [UTF8-LF sha256=a6dc69b69cba74db1fff54fd75b8940bf88958404f6c3ac971b27797d5b13c50; anchor=inline bool uartWindowReady(]`. -- Target: `firmware/esp/application.ino:1240 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=uint8_t defaultPhysicalCell]`. -- Target: `tools/build-firmware.ps1:32 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor=CellChaseOnBoot]`. +- Target: `firmware/esp/application.ino:1328 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=uint8_t defaultPhysicalCell]`. +- Target: `tools/build-firmware.ps1:40 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor=CellChaseOnBoot]`. - Target: `firmware/esp32s3/src/LedOutput.h:13 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=#if !defined(PAKAR_S3_LED_GPIO_ENABLED)]`. - Target: `firmware/esp32s3/src/LedDmaGuard.h:16 [UTF8-LF sha256=6418b38a2773c592ccd48dd9c2cbfaaa1ff0a8f0aab99437e68c1a7c419a2a65; anchor=class LedDmaSetupScope]`. - Target: `firmware/esp32s3/src/LedDmaGuard.cpp:68 [UTF8-LF sha256=c2c2b90ee34d7919d22c0e4dc6c651c077b9acc0ed833090aab57e2c335e1d7e; anchor=newCacheSafeLedChannel]`. -- Target: `mobile/lib/pixel_diagnostic.dart:291 [UTF8-LF sha256=c5143cb176678151bc81273fbd7ebe7d666d9a145f400efc65ac1a8e7ecb817c; anchor=class PixelProbe]`. +- Target: `mobile/lib/pixel_diagnostic.dart:301 [UTF8-LF sha256=f454f8dd79f7ac69a63f8dd16a63ef6e6d34fd51dbfcd16113aed3a278f9ec75; anchor=class PixelProbe]`. - Target: `mobile/lib/pixel_diagnostic_page.dart:14 [UTF8-LF sha256=804a63cddd3a7fa6d15cdf63d12eab70200fcd80bd527a1f93b715d2db6ae824; anchor=class PixelDiagnosticPage]`. -- Target: `mobile/lib/api.dart:372 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future pixelDiagnostic(]`. +- Target: `mobile/lib/api.dart:393 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future pixelDiagnostic(]`. - Target: `mobile/tool/pixel_probe.dart:65 [UTF8-LF sha256=b27e1a4ed0a664ef350400d452eff575463f12a975049e29ac70f3dd88d5d21a; anchor=Future main(]`. - Target: `firmware/esp/src/CloudAnimationProgress.h:8 [UTF8-LF sha256=97f560579ba0bb5f9172c29e92078471b6e8cbe64b5d961de2740a44cc5f714d; anchor=bool serviceDuringCloud(]`. - Target: `desktop/src/pixel_diagnostic.rs:8 [UTF8-LF sha256=5c2bb4db8bf95fc370c1b6fad2e11477cd76adad8f574304c7828e566a431108; anchor=pub struct Probe]`. -- Target: `desktop/src/native_workspace.rs:64 [UTF8-LF sha256=5003829240d453d4917fa4e07dc74474b999a8d4bf9adcf661b36334b550da60; anchor=pub pixel_probe:]`. -- Target: `desktop/src/ui/shell.rs:2582 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn send_pixel_probe(]`. +- Target: `desktop/src/native_workspace.rs:66 [UTF8-LF sha256=15688543f8999613f064f17e60d453f50d59a05c564e927379fb86d50bc4f0d8; anchor=pub pixel_probe:]`. +- Target: `desktop/src/ui/shell.rs:2705 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn send_pixel_probe(]`. - Target: `libraries/BoardCore/src/PixelDiagnostic.h:8 [UTF8-LF sha256=ed279cedb68663d0bfe93aae097e26fa0978f6642eb3c23dddc793a61522f8a2; anchor=struct PixelDiagnostic]`. -- Target: `firmware/esp/src/LedGrid.cpp:545 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=bool LedGrid::diagnostic(]`. -- Target: `firmware/esp/src/DiagnosticReceipt.h:9 [UTF8-LF sha256=55754a3b5197413ef778de9928decdd989b8c0d7b7b13c103f3f101ea94763b1; anchor=inline bool diagnosticReceiptStatus]`. -- Target: `firmware/esp/application.ino:2569 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=String buildUsbTableSnapshot(bool includePixels) {]`. +- Target: `firmware/esp/src/LedGrid.cpp:558 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=bool LedGrid::diagnostic(]`. +- Target: `firmware/esp/src/DiagnosticReceipt.h:9 [UTF8-LF sha256=160078ef95ee5212d37b57ab452128a20810c544a23d613f2d8a0d449024a8fc; anchor=inline bool diagnosticReceiptStatus]`. +- Target: `firmware/esp/application.ino:2606 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=String buildUsbTableSnapshot(bool includePixels) {]`. - Target: `libraries/BoardCore/src/PixelGroupLayout.h:9 [UTF8-LF sha256=b7463394fb83f9e74b2f296ccca3ca49f1cacc09a3a905c370dc469eb042844d; anchor=class PixelGroupLayout]`. +- Target: `libraries/BoardCore/src/MatrixFire.h:10 [UTF8-LF sha256=363613221446af62c42e845887b6485e2d84cb6f4cb6170a34d7d8b3ea81200f; anchor=template]`. +- Target: `firmware/esp/src/StateAppearanceSettings.h:20 [UTF8-LF sha256=c5c190a2ea68e57f62522ec5334fe749ec734fcd13b263a14fef57318a58ddcb; anchor=key=="fire_rotation"]`. - Retained: Actual canonical complete AddressableLeds source/header read through EOF, including staged RGB versus show ownership. The selection adapter validates all80 cells before one loop-owned state mutation; no per-cell network pacing, show calls, persistence or AVR fan-out. This is a scoped adaptation of stage/show discipline, not a copied AVR timing sender. - Retained: ESP owns synchronized300ms wall-clock interpolation and one shared published frame. Single-cell actions use the same renderer method; static fallback remains immediate. Partial/invalid selections never reach renderer state. - Retained: Stress regression reproduced/fixed unrelated committed raw pixels reverting to stale logical targets on an abandoned partial stream. Both renderer modes and10000 full80-cell host batches per mode pass; host CPU timing is not physical throughput. @@ -1428,17 +1659,18 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Retained: Pixel-workbench checkpoint extends the same donor staged-RGB/show ownership with one transient post-render diagnostic layer: bounded solid/blink/breathe/rainbow on one pixel or the baked six/twelve-pixel group; default clear and optional masked overlay share the existing mapping, correction and immutable output path. Base mode, winner, cell states, scene and original output policy survive the overlay; stopping restores them and never writes an animation frame to flash. No donor AVR timing loop or second effects engine was copied. - Retained: USB diagnostics retain one-shot correlated command execution and truthful publication state. An explicitly partial, same-board/boot receipt projects existing authoritative capabilities/driver/diagnostic fields and frame revision/pending metadata within the existing 8 KiB generic API budget. The general USB snapshot now checks its exact complete serialized envelope rather than an artificial 10240-byte status sublimit; all existing status/mapping/name/pixel fields and the 13200/16384-byte caps remain. Captured-state and maximum escaped-name fixtures exercise the previous failure without treating their synthetic frame metadata as live LED evidence. - Retained: Custom group sizing retains the shared staged-frame renderer and physical cable ownership: cumulative spans replace fixed six/twelve arithmetic, logical IDs and scene states remain unchanged, and unused tail pixels are black. Captured RGB carries capture-time layout metadata and revision-fenced diagnostics. Host renderer tests exercise table and first-wins roles; hardware output remains unaccepted for this candidate. +- Retained: 2026-10-28 complete AddressableLeds cpp/header reread at b0111ae0 preserves staged-frame/show ownership for Fire as well. The existing transport receives one fully composed frame; no donor AVR pin sender is copied. Separately attributed FastLED Fire2012 cooling/diffusion/ignition is adapted to a fixed160-byte staggered matrix field, cold explicit replay and bounded monotonic stepping. Saved direction uses the existing partial presentation patch/store and client readback, without renumbering cells, replacing corrections or changing the binary scene layout. Source/fixture evidence is not deployed optical acceptance; see REUSE-PREFLIGHT-RECEIPT.md. - Omitted/replaced code: `Fixed AVR D6/PORTD assembly, 16MHz interrupt masking, MacroQueue workspace and100-pixel/8-bit strip configuration`. Disposition: **replaced**. Reason: The final480-pixel renderer belongs to the ESP32-S3 GPIO4 (or ESP8266D2), not a second AVR strip. Reusing AVR registers/timing or truncating to100 pixels would conflict with confirmed hardware and memory ownership. Existing bounded PC contract remains a separate compatibility window. - Omitted/replaced code: `Donor native HAL showStrip and feature-profile brightness/renderedPixels buffers`. Disposition: **replaced**. Reason: The existing ESP output worker owns wire encoding/brightness and complete immutable frame copies. Host doubles test logical RGB and wire encoding separately; there is no second donor native HAL or duplicate brightness scale. - Omitted/replaced code: `Physical channel order/timing, RMT-DMA refill latency and full-buffer optical correctness`. Disposition: **pending**. Reason: The user confirmed pixels remained lit although the static backend reported complete transmissions and all1440 published RGB bytes were zero. That is a failed physical all-off acceptance, not a successful command. The explicit RMT recovery had350/700ns highs; the earlier candidate used250/600ns highs. After reading all eight WS2811V1.4 pages, the current RMT profile uses300/625ns highs and950/625ns LOW,1250ns bits and300us reset. New optional whole-frame GPIO4 RX-DMA digital capture remains undeployed/unverified and cannot measure strip DIN voltage. A witnessed task-scoped GDMA allocation requests cache-safe refill using the already-IRAM SDK graph; the ring still refills and partition flash guards remain. Incomplete SDK symbol accounting now fails closed; this is still not wire readback. Require visual all-black, whole-cell mapping, waveform/RF/refill stress and fault recovery; counters/error0 alone are not waveform or latch proof. -- Test-code evidence: `tests/led_grid_test.cpp:271 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=Entire selection validation]`. +- Test-code evidence: `tests/led_grid_test.cpp:323 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=Entire selection validation]`. - Test-code evidence: `tests/led_grid_stress_test.cpp:12 [UTF8-LF sha256=812517dc4c2973487d67d4cd294c149dcb66b527fc85716363f87c723fe5fa4a; anchor=constexpr unsigned iterations=10000;]`. -- Test-code evidence: `tests/led_worker_test.cpp:333 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void functional()]`. +- Test-code evidence: `tests/led_worker_test.cpp:333 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void functional()]`. - Test-code evidence: `tests/led_dma_guard_test.cpp:38 [UTF8-LF sha256=d69b5b0bd26348aabd176350120d11199748f2aceada1d39c113078719fa80c0; anchor=int main(]`. - Test-code evidence: `tools/test-led-dma-guard.ps1:14 [UTF8-LF sha256=f35494c76825ab2deccfe40759e9a7853142dc30875ffaa813f648d67de1b2e5; anchor=--wrap=gdma_new_ahb_channel]`. - Test-code evidence: `tests/cell_chase_wiring_test.mjs:9 [UTF8-LF sha256=e1659e2a3c12039a7c3f9bbd3c876ebdc467b85957ac6b5a5f51a367e4991507; anchor=const preset=]`. - Test-code evidence: `tests/build_storage_test.ps1:52 [UTF8-LF sha256=6bfae8dbd4e71ee19a6d760fe69898d3f057354877c3182dff73ee9240f1dbbd; anchor=$taskStaticS3]`. -- Test-code evidence: `mobile/test/pixel_diagnostic_test.dart:77 [UTF8-LF sha256=134ffbe9e72eb91a25460f2c4f4610777e44ac7a2dc45a21287370843fb96e7d; anchor=Every raw position clears]`. +- Test-code evidence: `mobile/test/pixel_diagnostic_test.dart:78 [UTF8-LF sha256=5864c45b07a12dc3e327228b35629fd58c9db99c5bf1a6ce3dbfb064d9cfe5e8; anchor=Every raw position clears]`. - Test-code evidence: `mobile/test/pixel_diagnostic_page_test.dart:615 [UTF8-LF sha256=719e05d13cd64b01eb41e87344f27ae9b4862123f1a469e0b6e7e2af78798fbe; anchor=Pending writes cannot repeat]`. - Test-code evidence: `mobile/test/pixel_diagnostic_transport_test.dart:97 [UTF8-LF sha256=253d183118520a978fbea6b6470cca2917fe6b45688bf6982601fbe86825f152; anchor=one complete frame, no replay]`. - Test-code evidence: `mobile/test/pixel_diagnostic_command_test.dart:157 [UTF8-LF sha256=dc315906d40ff502aa39343f9a17bec4af8092527bb0e34c82c1059135445173; anchor=Atomic pixel command]`. @@ -1446,18 +1678,23 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `tests/idf_rmt_ring_test.cpp:147 [UTF8-LF sha256=67b7f1107160e49db96f06907edb16dc540095bf4aaf6555041d49f47362cf00; anchor=void complete(]`. - Test-code evidence: `tests/idf_rmt_ring_sdk.inc:30 [UTF8-LF sha256=21407218c9fef3c223d298d4fc2084d9ab07b15a4de77a4cb43bd25c75c43d91; anchor=static size_t rmt_encode_bytes(]`. - Test-code evidence: `tests/idf_rmt_ring_provenance.json:2 [UTF8-LF sha256=882d1ef64d39ff9d99e303b7f997c1b7e6f11db82ae52f11c0a8b56a1075736f; anchor="sdk":]`. -- Test-code evidence: `tests/led_grid_test.cpp:51 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=void cloudAnimationProgress(]`. +- Test-code evidence: `tests/led_grid_test.cpp:101 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=void cloudAnimationProgress(]`. - Test-code evidence: `desktop/src/pixel_diagnostic.rs:643 [UTF8-LF sha256=5c2bb4db8bf95fc370c1b6fad2e11477cd76adad8f574304c7828e566a431108; anchor=fn pixel_diagnostic_remote_observation_is_ordered_and_never_actuates()]`. -- Test-code evidence: `desktop/src/realtime.rs:479 [UTF8-LF sha256=fc641c1365f2e20480f48c364ee97a20e61b39e8d850dc80a85abb128ab23ce4; anchor=fn realtime_pixel_diagnostic_requires_exact_complete_readback_and_typed_intent()]`. +- Test-code evidence: `desktop/src/realtime.rs:681 [UTF8-LF sha256=134977c4f18998b92ebb2e8d763147a1c0eeb2039c019a603cb7de712502fa01; anchor=fn realtime_pixel_diagnostic_requires_exact_complete_readback_and_typed_intent()]`. - Test-code evidence: `mobile/test/pixel_diagnostic_command_test.dart:16 [UTF8-LF sha256=dc315906d40ff502aa39343f9a17bec4af8092527bb0e34c82c1059135445173; anchor=Two real WS clients observe the same diagnostic]`. -- Test-code evidence: `tests/led_grid_test.cpp:383 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=Advanced diagnostics are one transient post-render layer, not a scene.]`. +- Test-code evidence: `tests/led_grid_test.cpp:435 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=Advanced diagnostics are one transient post-render layer, not a scene.]`. - Test-code evidence: `tests/game_role_renderer_test.cpp:98 [UTF8-LF sha256=2187d0dfed8415b8a19af5c8811f686860c8b1a1559b8424055d014cedb72c59; anchor=overlay.enabled=false]`. - Test-code evidence: `tests/scene_store_test.cpp:48 [UTF8-LF sha256=bde2386fcfab1043f052bcd9cf8d8ff2de2d478adfbdffd15aaa7c4a464e8a9e; anchor=Diagnostics never write mode, pattern, output policy or transient RGB]`. -- Test-code evidence: `tests/diagnostic_receipt_test.cpp:51 [UTF8-LF sha256=1b37fd01b15e2d65c66d9f9494375030162baf58adcc2baaef14b29058e8c96f; anchor=Captured status projection:]`. +- Test-code evidence: `tests/diagnostic_receipt_test.cpp:56 [UTF8-LF sha256=85a66d548c203d2f3f95c69a7c008580f95cd1c0cf024f7dc5a2aca8dab7d282; anchor=Captured status projection:]`. - Test-code evidence: `tests/usb_snapshot_budget_test.mjs:118 [UTF8-LF sha256=5ed04d90439eb98662bc6ec4a50b176e26558e209dc3d600a3ba5d33cf031378; anchor=Captured regression must exceed the old sublimit]`. - Test-code evidence: `tools/test-usb-api.ps1:1 [UTF8-LF sha256=6df4d45bc23e7c8624616053af6b5604d00e9297f8b6b72014eddb175219c605; anchor=param([string] $StatusCapture)]`. - Test-code evidence: `tests/pixel_group_layout_test.cpp:25 [UTF8-LF sha256=e90ecd6aa4c125eca53d631f14e28b9ac1cfe74ce05ca4ac8d10525867a5aba3; anchor=int main()]`. - Test-code evidence: `tests/pixel_group_renderer_test.cpp:6 [UTF8-LF sha256=fe614d76eca8e0afd62f3180bfe4f7621ae03c98e1b155dcf373807ca22fad45; anchor=int main()]`. +- Test-code evidence: `tests/matrix_fire_test.cpp:7 [UTF8-LF sha256=a33060673935f26ad6d6d1a56fa162c463a45140bb9010e9544644b7b97c3cf5; anchor=using Fire=]`. +- Test-code evidence: `tests/led_grid_test.cpp:67 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=void matrixFireRendering()]`. +- Test-code evidence: `tests/web_appearance_mount_test.mjs:46 [UTF8-LF sha256=6db5236e85ffc20b052684c16e625eaea04e05393d591abeb640b350e1b220a2; anchor=const sent=[]]`. +- Test-code evidence: `mobile/test/appearance_live_test.dart:26 [UTF8-LF sha256=77e70b48b5faf8f8ed73ccb5711b0c0ae12889e11d09870780d47a1d1c6a6b13; anchor=Fire direction follows saved readback]`. +- Test-code evidence: `desktop/src/state_appearance.rs:379 [UTF8-LF sha256=567e65fa7d3e0e7ad7ef508b7944c0f861b376fd348031dd105ad2df94ece5eb; anchor=fn validates_and_compares_only_requested_fields()]`. - Scoped advantage: One validated atomic board intent replaces client fan-out, without duplicating the effects engine or changing API generations. - Remaining: Physical pixel/group placement and Android phone acceptance remain open. Mobile widgets and loopback transport are separate evidence from board buffer and optical correctness; no donor-wide completion is implied. - Remaining: User reports first23 cells working and two-second pauses every5s; old cloud service measured2552ms. Actual-IDF encoder/EOF models compare all480pixels across12descriptor windows, but fault injection proves encoded_count11521 is not waveform proof. Require deployed IRQ receipt, live timing and optical confirmation after approved OTA. @@ -1468,28 +1705,28 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `a28009e6211056f0cdb1071e7232d6d277a27cf9dc8e3e9951be9830745f18d4`. - Target: `libraries/BoardCore/src/CellBatch.h:9 [UTF8-LF sha256=752eb7baf7d4974f60be098c70a17198ce21b6a973401a01a67b41915e648884; anchor=struct CellBatch]`. -- Target: `firmware/esp/src/LedGrid.cpp:70 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=bool LedGrid::setCells(]`. -- Target: `firmware/esp/src/LedGrid.h:78 [UTF8-LF sha256=35e977940f6601249c8ca1aa952cfe7598f911d28cd0186147d618454cee0501; anchor=bool setCells(]`. +- Target: `firmware/esp/src/LedGrid.cpp:72 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=bool LedGrid::setCells(]`. +- Target: `firmware/esp/src/LedGrid.h:86 [UTF8-LF sha256=d9df351a2859d24b56bb58b63b9ef19c146c19992bdefc708fa5707672d297ee; anchor=bool setCells(]`. - Target: `libraries/BoardCore/src/TableCellMapping.h:8 [UTF8-LF sha256=8a4d0d13b7779d8a54c74bb95cafcd871814423ee42909bcd5ebbd6006213ba0; anchor=constexpr uint8_t logicalForPhysical]`. -- Target: `firmware/esp32s3/src/LedOutput.cpp:203 [UTF8-LF sha256=3d6ce05b234191bb6d18eccff7764f7b87a28400af9fb6cf9619b19084114053; anchor=bool pending=false;]`. +- Target: `firmware/esp32s3/src/LedOutput.cpp:210 [UTF8-LF sha256=45954b1ad02507a049a80c190849317fc75fb639d011ee7debb8a0d2cf013ed0; anchor=bool pending=false;]`. - Target: `firmware/esp32s3/src/LedWire.h:37 [UTF8-LF sha256=9d83e653d1d78a30142037519bc5bf9f5ab6487a354e245403b8ff25fdcf7dda; anchor=inline constexpr uint16_t zeroHigh=12]`. - Target: `firmware/esp/src/LedRenderAdmission.h:7 [UTF8-LF sha256=a6dc69b69cba74db1fff54fd75b8940bf88958404f6c3ac971b27797d5b13c50; anchor=inline bool uartWindowReady(]`. -- Target: `firmware/esp/application.ino:1240 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=uint8_t defaultPhysicalCell]`. -- Target: `tools/build-firmware.ps1:32 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor=CellChaseOnBoot]`. +- Target: `firmware/esp/application.ino:1328 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=uint8_t defaultPhysicalCell]`. +- Target: `tools/build-firmware.ps1:40 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor=CellChaseOnBoot]`. - Target: `firmware/esp32s3/src/LedOutput.h:13 [UTF8-LF sha256=46f0e3cc947e5262451e6b3659bdfedb9cc3fa3d5de8bc4779ec6ddc97f81fec; anchor=#if !defined(PAKAR_S3_LED_GPIO_ENABLED)]`. - Target: `firmware/esp32s3/src/LedDmaGuard.h:16 [UTF8-LF sha256=6418b38a2773c592ccd48dd9c2cbfaaa1ff0a8f0aab99437e68c1a7c419a2a65; anchor=class LedDmaSetupScope]`. - Target: `firmware/esp32s3/src/LedDmaGuard.cpp:68 [UTF8-LF sha256=c2c2b90ee34d7919d22c0e4dc6c651c077b9acc0ed833090aab57e2c335e1d7e; anchor=newCacheSafeLedChannel]`. -- Target: `mobile/lib/pixel_diagnostic.dart:291 [UTF8-LF sha256=c5143cb176678151bc81273fbd7ebe7d666d9a145f400efc65ac1a8e7ecb817c; anchor=class PixelProbe]`. +- Target: `mobile/lib/pixel_diagnostic.dart:301 [UTF8-LF sha256=f454f8dd79f7ac69a63f8dd16a63ef6e6d34fd51dbfcd16113aed3a278f9ec75; anchor=class PixelProbe]`. - Target: `mobile/lib/pixel_diagnostic_page.dart:14 [UTF8-LF sha256=804a63cddd3a7fa6d15cdf63d12eab70200fcd80bd527a1f93b715d2db6ae824; anchor=class PixelDiagnosticPage]`. -- Target: `mobile/lib/api.dart:372 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future pixelDiagnostic(]`. +- Target: `mobile/lib/api.dart:393 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future pixelDiagnostic(]`. - Target: `mobile/tool/pixel_probe.dart:65 [UTF8-LF sha256=b27e1a4ed0a664ef350400d452eff575463f12a975049e29ac70f3dd88d5d21a; anchor=Future main(]`. - Target: `desktop/src/pixel_diagnostic.rs:8 [UTF8-LF sha256=5c2bb4db8bf95fc370c1b6fad2e11477cd76adad8f574304c7828e566a431108; anchor=pub struct Probe]`. -- Target: `desktop/src/native_workspace.rs:64 [UTF8-LF sha256=5003829240d453d4917fa4e07dc74474b999a8d4bf9adcf661b36334b550da60; anchor=pub pixel_probe:]`. -- Target: `desktop/src/ui/shell.rs:2582 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn send_pixel_probe(]`. +- Target: `desktop/src/native_workspace.rs:66 [UTF8-LF sha256=15688543f8999613f064f17e60d453f50d59a05c564e927379fb86d50bc4f0d8; anchor=pub pixel_probe:]`. +- Target: `desktop/src/ui/shell.rs:2705 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn send_pixel_probe(]`. - Target: `libraries/BoardCore/src/PixelDiagnostic.h:8 [UTF8-LF sha256=ed279cedb68663d0bfe93aae097e26fa0978f6642eb3c23dddc793a61522f8a2; anchor=struct PixelDiagnostic]`. -- Target: `firmware/esp/src/LedGrid.cpp:545 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=bool LedGrid::diagnostic(]`. -- Target: `firmware/esp/src/DiagnosticReceipt.h:9 [UTF8-LF sha256=55754a3b5197413ef778de9928decdd989b8c0d7b7b13c103f3f101ea94763b1; anchor=inline bool diagnosticReceiptStatus]`. -- Target: `firmware/esp/application.ino:2569 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=String buildUsbTableSnapshot(bool includePixels) {]`. +- Target: `firmware/esp/src/LedGrid.cpp:558 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=bool LedGrid::diagnostic(]`. +- Target: `firmware/esp/src/DiagnosticReceipt.h:9 [UTF8-LF sha256=160078ef95ee5212d37b57ab452128a20810c544a23d613f2d8a0d449024a8fc; anchor=inline bool diagnosticReceiptStatus]`. +- Target: `firmware/esp/application.ino:2606 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=String buildUsbTableSnapshot(bool includePixels) {]`. - Retained: Actual canonical complete AddressableLeds source/header read through EOF, including staged RGB versus show ownership. The selection adapter validates all80 cells before one loop-owned state mutation; no per-cell network pacing, show calls, persistence or AVR fan-out. This is a scoped adaptation of stage/show discipline, not a copied AVR timing sender. - Retained: ESP owns synchronized300ms wall-clock interpolation and one shared published frame. Single-cell actions use the same renderer method; static fallback remains immediate. Partial/invalid selections never reach renderer state. - Retained: Stress regression reproduced/fixed unrelated committed raw pixels reverting to stale logical targets on an abandoned partial stream. Both renderer modes and10000 full80-cell host batches per mode pass; host CPU timing is not physical throughput. @@ -1504,25 +1741,25 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `Fixed AVR D6/PORTD assembly, 16MHz interrupt masking, MacroQueue workspace and100-pixel/8-bit strip configuration`. Disposition: **replaced**. Reason: The final480-pixel renderer belongs to the ESP32-S3 GPIO4 (or ESP8266D2), not a second AVR strip. Reusing AVR registers/timing or truncating to100 pixels would conflict with confirmed hardware and memory ownership. Existing bounded PC contract remains a separate compatibility window. - Omitted/replaced code: `Donor native HAL showStrip and feature-profile brightness/renderedPixels buffers`. Disposition: **replaced**. Reason: The existing ESP output worker owns wire encoding/brightness and complete immutable frame copies. Host doubles test logical RGB and wire encoding separately; there is no second donor native HAL or duplicate brightness scale. - Omitted/replaced code: `Physical channel order/timing, RMT-DMA refill latency and full-buffer optical correctness`. Disposition: **pending**. Reason: The user confirmed pixels remained lit although the static backend reported complete transmissions and all1440 published RGB bytes were zero. That is a failed physical all-off acceptance, not a successful command. The explicit RMT recovery had350/700ns highs; the earlier candidate used250/600ns highs. After reading all eight WS2811V1.4 pages, the current RMT profile uses300/625ns highs and950/625ns LOW,1250ns bits and300us reset. New optional whole-frame GPIO4 RX-DMA digital capture remains undeployed/unverified and cannot measure strip DIN voltage. A witnessed task-scoped GDMA allocation requests cache-safe refill using the already-IRAM SDK graph; the ring still refills and partition flash guards remain. Incomplete SDK symbol accounting now fails closed; this is still not wire readback. Require visual all-black, whole-cell mapping, waveform/RF/refill stress and fault recovery; counters/error0 alone are not waveform or latch proof. -- Test-code evidence: `tests/led_grid_test.cpp:271 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=Entire selection validation]`. +- Test-code evidence: `tests/led_grid_test.cpp:323 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=Entire selection validation]`. - Test-code evidence: `tests/led_grid_stress_test.cpp:12 [UTF8-LF sha256=812517dc4c2973487d67d4cd294c149dcb66b527fc85716363f87c723fe5fa4a; anchor=constexpr unsigned iterations=10000;]`. -- Test-code evidence: `tests/led_worker_test.cpp:333 [UTF8-LF sha256=974f5220db05c905d74771294cff1486ef0a35fd13926678bbf12bb3c744c6c0; anchor=void functional()]`. +- Test-code evidence: `tests/led_worker_test.cpp:333 [UTF8-LF sha256=b049e7e09ed4d47bd22f614a4f6e322b82727c01ff1ad1f0ca65ef27f83e8d4d; anchor=void functional()]`. - Test-code evidence: `tests/led_dma_guard_test.cpp:38 [UTF8-LF sha256=d69b5b0bd26348aabd176350120d11199748f2aceada1d39c113078719fa80c0; anchor=int main(]`. - Test-code evidence: `tools/test-led-dma-guard.ps1:14 [UTF8-LF sha256=f35494c76825ab2deccfe40759e9a7853142dc30875ffaa813f648d67de1b2e5; anchor=--wrap=gdma_new_ahb_channel]`. - Test-code evidence: `tests/cell_chase_wiring_test.mjs:9 [UTF8-LF sha256=e1659e2a3c12039a7c3f9bbd3c876ebdc467b85957ac6b5a5f51a367e4991507; anchor=const preset=]`. - Test-code evidence: `tests/build_storage_test.ps1:52 [UTF8-LF sha256=6bfae8dbd4e71ee19a6d760fe69898d3f057354877c3182dff73ee9240f1dbbd; anchor=$taskStaticS3]`. -- Test-code evidence: `mobile/test/pixel_diagnostic_test.dart:77 [UTF8-LF sha256=134ffbe9e72eb91a25460f2c4f4610777e44ac7a2dc45a21287370843fb96e7d; anchor=Every raw position clears]`. +- Test-code evidence: `mobile/test/pixel_diagnostic_test.dart:78 [UTF8-LF sha256=5864c45b07a12dc3e327228b35629fd58c9db99c5bf1a6ce3dbfb064d9cfe5e8; anchor=Every raw position clears]`. - Test-code evidence: `mobile/test/pixel_diagnostic_page_test.dart:615 [UTF8-LF sha256=719e05d13cd64b01eb41e87344f27ae9b4862123f1a469e0b6e7e2af78798fbe; anchor=Pending writes cannot repeat]`. - Test-code evidence: `mobile/test/pixel_diagnostic_transport_test.dart:97 [UTF8-LF sha256=253d183118520a978fbea6b6470cca2917fe6b45688bf6982601fbe86825f152; anchor=one complete frame, no replay]`. - Test-code evidence: `mobile/test/pixel_diagnostic_command_test.dart:157 [UTF8-LF sha256=dc315906d40ff502aa39343f9a17bec4af8092527bb0e34c82c1059135445173; anchor=Atomic pixel command]`. - Test-code evidence: `tests/mobile_pixel_probe_plan_test.ps1:8 [UTF8-LF sha256=823dd963abd25514c0557bfc351dd23d24111f6de85a17461b91a7abd7f8287d; anchor=canonical compile-only plan]`. - Test-code evidence: `desktop/src/pixel_diagnostic.rs:643 [UTF8-LF sha256=5c2bb4db8bf95fc370c1b6fad2e11477cd76adad8f574304c7828e566a431108; anchor=fn pixel_diagnostic_remote_observation_is_ordered_and_never_actuates()]`. -- Test-code evidence: `desktop/src/realtime.rs:479 [UTF8-LF sha256=fc641c1365f2e20480f48c364ee97a20e61b39e8d850dc80a85abb128ab23ce4; anchor=fn realtime_pixel_diagnostic_requires_exact_complete_readback_and_typed_intent()]`. +- Test-code evidence: `desktop/src/realtime.rs:681 [UTF8-LF sha256=134977c4f18998b92ebb2e8d763147a1c0eeb2039c019a603cb7de712502fa01; anchor=fn realtime_pixel_diagnostic_requires_exact_complete_readback_and_typed_intent()]`. - Test-code evidence: `mobile/test/pixel_diagnostic_command_test.dart:16 [UTF8-LF sha256=dc315906d40ff502aa39343f9a17bec4af8092527bb0e34c82c1059135445173; anchor=Two real WS clients observe the same diagnostic]`. -- Test-code evidence: `tests/led_grid_test.cpp:383 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=Advanced diagnostics are one transient post-render layer, not a scene.]`. +- Test-code evidence: `tests/led_grid_test.cpp:435 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=Advanced diagnostics are one transient post-render layer, not a scene.]`. - Test-code evidence: `tests/game_role_renderer_test.cpp:98 [UTF8-LF sha256=2187d0dfed8415b8a19af5c8811f686860c8b1a1559b8424055d014cedb72c59; anchor=overlay.enabled=false]`. - Test-code evidence: `tests/scene_store_test.cpp:48 [UTF8-LF sha256=bde2386fcfab1043f052bcd9cf8d8ff2de2d478adfbdffd15aaa7c4a464e8a9e; anchor=Diagnostics never write mode, pattern, output policy or transient RGB]`. -- Test-code evidence: `tests/diagnostic_receipt_test.cpp:51 [UTF8-LF sha256=1b37fd01b15e2d65c66d9f9494375030162baf58adcc2baaef14b29058e8c96f; anchor=Captured status projection:]`. +- Test-code evidence: `tests/diagnostic_receipt_test.cpp:56 [UTF8-LF sha256=85a66d548c203d2f3f95c69a7c008580f95cd1c0cf024f7dc5a2aca8dab7d282; anchor=Captured status projection:]`. - Test-code evidence: `tests/usb_snapshot_budget_test.mjs:118 [UTF8-LF sha256=5ed04d90439eb98662bc6ec4a50b176e26558e209dc3d600a3ba5d33cf031378; anchor=Captured regression must exceed the old sublimit]`. - Test-code evidence: `tools/test-usb-api.ps1:1 [UTF8-LF sha256=6df4d45bc23e7c8624616053af6b5604d00e9297f8b6b72014eddb175219c605; anchor=param([string] $StatusCapture)]`. - Scoped advantage: One validated atomic board intent replaces client fan-out, without duplicating the effects engine or changing API generations. @@ -1540,8 +1777,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `Immediate clear/write when both EEPROM banks are invalid; publishing active generation before verifying committed bank`. Disposition: **replaced**. Reason: Pakar retains a volatile empty state until an explicit user mutation and verifies the actual inactive-bank commit before promoting it. This preserves existing EEPROM and allows failure reporting instead of falsely accepting a torn write. - Omitted/replaced code: `Four-key eventValue bound and donor UART CRC namespace`. Disposition: **replaced**. Reason: This board has nine confirmed inputs; valid key IDs0..8 replace donor0..3. Shared WireCodec provides the same CRC algorithm without importing a second UART protocol engine. - Omitted/replaced code: `Compiling AVR EEPROM owner into ESP transport targets`. Disposition: **replaced**. Reason: Both ESP architectures proxy PCController operations to the AVR. The observed S3 SDK EEPROM.update error exposed an overly narrow ESP8266-only guard; the guard now excludes both families and their architecture macros. AVR/host fixture code remains intact. -- Test-code evidence: `tests/pc_contract_test.cpp:144 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=void macroAndAutomations() {]`. -- Test-code evidence: `tests/s3_platform_wiring_test.mjs:88 [UTF8-LF sha256=2f224ecd6b8eade6e985bf64244690a8a93347fe6e95ab0b4646edfaa823412a; anchor=assert.match(automationStore,]`. +- Test-code evidence: `tests/pc_contract_test.cpp:147 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=void macroAndAutomations() {]`. +- Test-code evidence: `tests/s3_platform_wiring_test.mjs:91 [UTF8-LF sha256=778c0fee4df01440d52814b64363f0fba85d235edc457b4bae2739e94cf4e662; anchor=assert.match(automationStore,]`. - Remaining: Run exact-source AVR firmware and integrated ESP32-S3/ESP8266 builds, physical automation/EEPROM readback and interruption tests on the actual board. Existing host fixtures/source syntax checks are not physical EEPROM, power-loss, actuator or integrated SDK acceptance. ### PCController: `Project/AutomationStore.h` @@ -1552,7 +1789,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `libraries/BoardCore/src/pccontroller/AutomationStore.h:141 [UTF8-LF sha256=881d09b14a807e8907209e6e14aa87d630313051180f01975b88d0c21b64d98f; anchor=class AutomationExecutor {]`. - Retained: Complete actual donor/current header read. Retains event/action enums and allowed flags, packed twelve-byte UART record static assertion, compact stored record/header layouts, dual-bank API, executor budgets and explicit action callback. Adds initialized_ to represent volatile blank storage truthfully. - Omitted/replaced code: `Unrelated ProjectConfig include and donor RF/reset/status-profile EEPROM allocation`. Disposition: **replaced**. Reason: Pakar uses its existing explicit disjoint settings/automation/Prog layout, not the donor's other-board persistent image. The new architecture exclusion applies to the implementation, not the portable record contract; optional hardware actions remain gated by actual runtime capabilities. -- Test-code evidence: `tests/pc_contract_test.cpp:162 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=AutomationRecord rec{]`. +- Test-code evidence: `tests/pc_contract_test.cpp:165 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=AutomationRecord rec{]`. - Remaining: Run exact-source AVR firmware and integrated ESP32-S3/ESP8266 builds, physical automation/EEPROM readback and interruption tests on the actual board. Existing host fixtures/source syntax checks are not physical EEPROM, power-loss, actuator or integrated SDK acceptance. ### PCController: `Project/BootMelody.cpp` @@ -1563,7 +1800,7 @@ Verdict: **scoped-improvement**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920 - Target: `libraries/BoardCore/src/AudioCues.h:24 [UTF8-LF sha256=514bc5c511ab6609ea79486485f01e2ee01a182a6a1e74e1492b4856ee27c460; anchor=case Protocol::kCueAttention:]`. - Retained: Actual b0111ae0 header/implementation read through EOF; preserve the exact 1032/2010/2400Hz 70/60/70/60/120/150ms nonblocking welcome schedule and replace-current-queue semantics. The existing Timer1 player also owns additive finite attention/update melodies; no delay loop, boot replay or frequency approximation introduced. - No production-code omission claimed for this reviewed file. -- Test-code evidence: `tests/tone_player_test.cpp:76 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const uint16_t durations[kCueCount]]`. +- Test-code evidence: `tests/tone_player_test.cpp:107 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const uint16_t durations[kCueCount]]`. - Test-code evidence: `tests/attention_cues_test.cpp:31 [UTF8-LF sha256=2b6bd229d5c9b9c5bdc233b97b88ca4d3422e34fad2dcb20dbb11db46732b3b0; anchor=Finite update notices:]`. - Scoped advantage: Existing deadline-preserving player retains donor welcome while exposing explicit ACK-gated finite attention without blocking ESP services. - Remaining: Live AVR/phone frequency/cadence, Silent/Prog and connected S3 update acceptance remain open; ASUS SSH8022 refused. No source fixture certifies physical acoustics. @@ -1576,7 +1813,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `libraries/BoardCore/src/AudioCues.h:24 [UTF8-LF sha256=514bc5c511ab6609ea79486485f01e2ee01a182a6a1e74e1492b4856ee27c460; anchor=case Protocol::kCueAttention:]`. - Retained: Actual b0111ae0 header/implementation read through EOF; preserve the exact 1032/2010/2400Hz 70/60/70/60/120/150ms nonblocking welcome schedule and replace-current-queue semantics. The existing Timer1 player also owns additive finite attention/update melodies; no delay loop, boot replay or frequency approximation introduced. - Omitted/replaced code: `../LocalLib/TonePlayer.h include path`. Disposition: **replaced**. Reason: Pakar Arduino sketch is flat; use its existing TonePlayer.h without copying another hardware owner. -- Test-code evidence: `tests/tone_player_test.cpp:76 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const uint16_t durations[kCueCount]]`. +- Test-code evidence: `tests/tone_player_test.cpp:107 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const uint16_t durations[kCueCount]]`. - Test-code evidence: `tests/attention_cues_test.cpp:31 [UTF8-LF sha256=2b6bd229d5c9b9c5bdc233b97b88ca4d3422e34fad2dcb20dbb11db46732b3b0; anchor=Finite update notices:]`. - Scoped advantage: Existing deadline-preserving player retains donor welcome while exposing explicit ACK-gated finite attention without blocking ESP services. - Remaining: Live AVR/phone frequency/cadence, Silent/Prog and connected S3 update acceptance remain open; ASUS SSH8022 refused. No source fixture certifies physical acoustics. @@ -1589,8 +1826,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp32s3/src/LcdPort.cpp:3 [UTF8-LF sha256=97830b8c55a9a5214edfd537ab8426696f79625ab7cf30183b14066b19ef9c94; anchor=void LcdPort::begin(]`. - Target: `firmware/esp/src/LcdManagement.h:3 [UTF8-LF sha256=eb800ee4c334b22504f37bd841bda58a7562052df2c7226ff19bc23a454e652d; anchor=String lcdStatusJson()]`. - Target: `desktop/src/display.rs:7 [UTF8-LF sha256=9d73ae63aaa8a2bc4b824219c9cfab9f7924ae7566be7a9206c11b6344a2f18b; anchor=pub struct LcdObservation]`. -- Target: `mobile/lib/peer_context.dart:284 [UTF8-LF sha256=e2d91e078d6dad7454eaa68f9a030ef1aa8ae2c91ba72ba522818da6b74131b8; anchor=bool lcdObservationReady(]`. -- Target: `firmware/esp/src/WebUi.h:2260 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function lcdObservation(]`. +- Target: `mobile/lib/peer_context.dart:302 [UTF8-LF sha256=c8602a12ccd3009028068aba091d46900c8aa0e345a4426ffe80acfb716362fc; anchor=bool lcdObservationReady(]`. +- Target: `firmware/esp/src/WebUi.h:2520 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function lcdObservation(]`. - Retained: Complete actual donor source read through EOF. Preserved bounded transaction results, explicit master setup and timeout policy through the real S3 Wire1 adapter at100kHz/5ms transaction timeout. Transport interface isolates controller-specific registers instead of pretending AVR TWI can control an ESP32-S3 bus. - Omitted/replaced code: `AVR TWI register implementation, native AVR bridge receive buffer and host-proxied arbitrary I2C operations`. Disposition: **replaced**. Reason: The S3 uses actual native hardware I2C on separate pins, not the AVR connector already carrying non-I2C TM1637. A private adapter has no public arbitrary-bus mutation path. Copying AVR registers or enabling Wire on AVR A4/A5 would steal the display pins. - Omitted/replaced code: `Flash-string overload and LCD clear/backlight/generic bus controls on all client surfaces; host-owned LCD prompt/priority/offline policy`. Disposition: **pending**. Reason: This slice implements standalone physical discovery/init/default cell/update text, finite REST presentation and exact completed-frame previews. It does not claim the full donor host presenter, raw I2C bridge, every app editor or USB LCD mutation parity. CompactI2c remains available for a future explicit nonconflicting AVR wiring/build profile after flash/RAM review. @@ -1611,8 +1848,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp32s3/src/LcdPort.cpp:3 [UTF8-LF sha256=97830b8c55a9a5214edfd537ab8426696f79625ab7cf30183b14066b19ef9c94; anchor=void LcdPort::begin(]`. - Target: `firmware/esp/src/LcdManagement.h:3 [UTF8-LF sha256=eb800ee4c334b22504f37bd841bda58a7562052df2c7226ff19bc23a454e652d; anchor=String lcdStatusJson()]`. - Target: `desktop/src/display.rs:7 [UTF8-LF sha256=9d73ae63aaa8a2bc4b824219c9cfab9f7924ae7566be7a9206c11b6344a2f18b; anchor=pub struct LcdObservation]`. -- Target: `mobile/lib/peer_context.dart:284 [UTF8-LF sha256=e2d91e078d6dad7454eaa68f9a030ef1aa8ae2c91ba72ba522818da6b74131b8; anchor=bool lcdObservationReady(]`. -- Target: `firmware/esp/src/WebUi.h:2260 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function lcdObservation(]`. +- Target: `mobile/lib/peer_context.dart:302 [UTF8-LF sha256=c8602a12ccd3009028068aba091d46900c8aa0e345a4426ffe80acfb716362fc; anchor=bool lcdObservationReady(]`. +- Target: `firmware/esp/src/WebUi.h:2520 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function lcdObservation(]`. - Retained: Complete actual donor source read through EOF. Preserved bounded transaction results, explicit master setup and timeout policy through the real S3 Wire1 adapter at100kHz/5ms transaction timeout. Transport interface isolates controller-specific registers instead of pretending AVR TWI can control an ESP32-S3 bus. - Omitted/replaced code: `AVR TWI register implementation, native AVR bridge receive buffer and host-proxied arbitrary I2C operations`. Disposition: **replaced**. Reason: The S3 uses actual native hardware I2C on separate pins, not the AVR connector already carrying non-I2C TM1637. A private adapter has no public arbitrary-bus mutation path. Copying AVR registers or enabling Wire on AVR A4/A5 would steal the display pins. - Omitted/replaced code: `Flash-string overload and LCD clear/backlight/generic bus controls on all client surfaces; host-owned LCD prompt/priority/offline policy`. Disposition: **pending**. Reason: This slice implements standalone physical discovery/init/default cell/update text, finite REST presentation and exact completed-frame previews. It does not claim the full donor host presenter, raw I2C bridge, every app editor or USB LCD mutation parity. CompactI2c remains available for a future explicit nonconflicting AVR wiring/build profile after flash/RAM review. @@ -1699,9 +1936,9 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `4b61348838f59e54efec329f8c9ba9dc3239e96139da114801a04a46867a78ed`. - Target: `firmware/avr/FrontPanel.cpp:252 [UTF8-LF sha256=d90acc624b6d9e9248238eb5015a573bb3081bde5546b9bdc3d1e14661f3dea4; anchor=void FrontPanel::service(]`. -- Target: `firmware/avr/PcControllerRuntime.cpp:456 [UTF8-LF sha256=247a68576d68de47fe261f13c0d04ea5013acbf019c87ae220f7aadd0dea9d22; anchor=espTouchedAt_]`. +- Target: `firmware/avr/PcControllerRuntime.cpp:491 [UTF8-LF sha256=7e419d2c63737a20f3bdcce8fece569e792c78471c82053cb4ed62f82582cb7b; anchor=espTouchedAt_]`. - Target: `firmware/esp/src/SemanticPanel.h:29 [UTF8-LF sha256=8cd5384e86c8201e42d6d80b96eacd84668bcb3a7ee88bacef5addb4be1bc958; anchor=class Presenter]`. -- Target: `firmware/esp/src/SemanticPanelService.h:56 [UTF8-LF sha256=4ca620dd032ec408d841284a408b866c4c3313bfb7eef594db2f4ad672e93ffd; anchor=void serviceSemanticPanel(]`. +- Target: `firmware/esp/src/SemanticPanelService.h:56 [UTF8-LF sha256=6cdc2690d8fa3f256eeecdec93bc58c726d937c60abb6ca1420d364a09916584; anchor=void serviceSemanticPanel(]`. - Retained: Actual complete1379-line donor read through EOF at b0111ae0c80dc34149ea1044de7dd5488920301b. Adapts holdMenuLabel650ms, label/value cadence, finite flash-message expiry, editor/programming priority and showHostSegmentText one-pass terminal-blank/explicit repeat behavior. Confirmed A4/A5 TM1637 mapping and explicit capture remain unchanged. - Retained: A bounded loop-owned semantic presenter retains cell/pixel/effect/winner/output context, timed notices, one-pass marquee, slow/double-fast blink and measured update progress. Requested WS/USB events remain separate from actual AVR segment/ACK observations. Periodic offers do not renew the15-second physical ESP-menu inactivity lease. - Omitted/replaced code: `relay/motion/door/BT/RF/sensor/PWM-specific display pages and EEPROM editors`. Disposition: **pending**. Reason: Read but not fitted or individually acknowledged on this table. Safety-sensitive actuator/sensor behavior remains capability-gated and needs separate matching-hardware review; no full editor/automation parity is claimed. @@ -1725,23 +1962,24 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `7309fd36f0680722a4260e8f1251ae5a0fc67e99fc60f2426bb1294f789e87d4`. -- Target: `firmware/esp/application.ino:1364 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=void serviceScenePersistence(bool flush)]`. +- Target: `firmware/esp/application.ino:1452 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=void serviceScenePersistence(bool flush)]`. - Target: `firmware/esp/src/LedPreferences.h:29 [UTF8-LF sha256=303c1469003fec6fc2777ce459c1fae872c73d67241871463b7a32135bd51aac; anchor=inline bool applyLedBootPreferences(]`. - Target: `desktop/src/board_silent.rs:128 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=pub struct Transaction]`. - Target: `desktop/src/ui/preferences.rs:262 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn board_controls(]`. -- Target: `mobile/lib/api.dart:1882 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future boardSound(]`. +- Target: `mobile/lib/api.dart:2109 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future boardSound(]`. - Target: `mobile/lib/board_sound_preferences.dart:10 [UTF8-LF sha256=888e518c1a85ff594b6c4c778f2eb770a4cbcad3a1d53d3defea19b5e226dc5d; anchor=class BoardSoundPreferences]`. -- Target: `firmware/esp/src/WebUi.h:3124 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=async function boardSoundTransaction(]`. -- Target: `desktop/src/ui/device_settings.rs:54 [UTF8-LF sha256=6988aa130e2f23e44b0b1fbb3f77e7ae4f221babcc4c3ed21ce0726e06c01ed3; anchor=fn avr_page(]`. -- Target: `desktop/src/device_settings.rs:646 [UTF8-LF sha256=10318a349cc0e812281b6ef106460774c97a410ee0a30a6afd11ac04424d1a32; anchor=pub fn finish_avr(]`. +- Target: `firmware/esp/src/WebUi.h:3542 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=async function boardSoundTransaction(]`. +- Target: `desktop/src/ui/device_settings.rs:54 [UTF8-LF sha256=c087cbbde3bfc194b6002b10a3b63bb16ac4e253ef33d1a6f951ab2e566a42f6; anchor=fn avr_page(]`. +- Target: `desktop/src/device_settings.rs:648 [UTF8-LF sha256=5bce7f14b9d6225d9aaa660838b8d07f7e14bda364c520a51f1c92e83d302ed6; anchor=pub fn finish_avr(]`. - Target: `firmware/esp/src/SceneStore.h:17 [UTF8-LF sha256=4877f4a54061ba25e69104055303781c84eb66f7f5eb49e3ec258370665a7bd2; anchor=template class SceneStore]`. -- Target: `firmware/esp/src/PixelCorrectionStore.h:9 [UTF8-LF sha256=2f1255aa95ec7d9cb3da57d18f3b6f43d4b95355b0cf52e57042cec643a18b9a; anchor=template class PixelCorrectionStore]`. +- Target: `firmware/esp/src/PixelCorrectionStore.h:9 [UTF8-LF sha256=2df74bce57295b7e2127aebd3f77ee52e35953162a8240c51510a6e11c1a708d; anchor=template class PixelCorrectionStore]`. - Target: `firmware/esp/src/LedScene.h:5 [UTF8-LF sha256=33e89e209eecc59de508241e51334285b767b53fb7ee0b9293ccf961e866ddd4; anchor=inline SceneState captureLedScene]`. - Target: `firmware/esp/src/AvrSceneSync.h:5 [UTF8-LF sha256=4863dd897abd06a207994d15f94ede88c0135650c415042922e2cd0193ffdfab; anchor=class AvrSceneSync]`. - Target: `firmware/esp/src/LedBackendSettings.h:9 [UTF8-LF sha256=dc55f78ab04e2fa5794dde958af77208cad588a16d257c98f855dcad491d81fb; anchor=template class LedBackendSettings]`. - Target: `libraries/BoardCore/src/PixelCorrections.h:12 [UTF8-LF sha256=2cffe251bb4f569b99cee3f3a6798f854c502cc983c57355e7947e40ce9f3c4d; anchor=bool enabled = true]`. -- Target: `firmware/esp/src/PixelCorrectionStore.h:64 [UTF8-LF sha256=2f1255aa95ec7d9cb3da57d18f3b6f43d4b95355b0cf52e57042cec643a18b9a; anchor=const char *preview(JsonObjectConst doc,uint32_t now)]`. +- Target: `firmware/esp/src/PixelCorrectionStore.h:64 [UTF8-LF sha256=2df74bce57295b7e2127aebd3f77ee52e35953162a8240c51510a6e11c1a708d; anchor=const char *preview(JsonObjectConst doc,uint32_t now)]`. - Target: `firmware/esp/src/PixelGroupStore.h:8 [UTF8-LF sha256=9153156602bcbb9d725506c9ea3d87d0a6c5e4cdce56fa42c78b8418ded09d52; anchor=class PixelGroupStore]`. +- Target: `firmware/esp/src/HostPresentationStore.h:180 [UTF8-LF sha256=b4db20cf50260b0498205c5ead521704f4dd6dbc36013f7a9f6e40481ada4aac; anchor=static const char *stagingPath()]`. - Retained: Reread the complete matching SettingsStore.cpp/header. Adapt actual markDirty/service1500ms/unchanged-byte wear reduction to ESP-owned SceneStore, with a bounded10s maximum edit delay and10s failed-write retry cooldown. The new private96-byte record stores all80 logical white/red/off states, mode/effect, brightness, output/static selection and front-panel cursor/winner. No animation, stream or chase frame becomes a per-frame flash write. - Retained: Replace the previous white-every-boot policy with stage/close/exact-readback/atomic-rename scene persistence. Read the old6-byte brightness record only for migration; it remains untouched. Restore the validated scene and mapping before first LED publication, keep safe white initial defaults if no valid scene, and expose pending/saved/error/write counters. AVR reacquisition and its SetMode-induced reset revision are baselines, not new Reset/key actions. App caches never restore hardware authority or replay queued writes. - Retained: Reread complete donor source through EOF. Adapt MCU-owned persistent Silent bit independent of host/Windows notification audio, capability-acknowledged controls, explicit unknown/pending/verified states and full-record preservation. Native Preferences Board page uses existing PCController GET/SET/GET with real AVR ACK and persistent readback over the selected fenced HTTP or USB owner only; Web and Flutter Preferences use the same living contract. No unsupported buzzer is invented, no automatic uncertain mutation replay, and unchanged persisted flags skip EEPROM writes. @@ -1751,19 +1989,20 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Retained: Runtime-backend checkpoint reread the complete ledger-bound b0111ae0 Project/SettingsStore.cpp via immutable git show; the newer local checkout has different bytes and is not relabelled as matching this source. Adapt validated MCU-owned settings and EEPROM.update unchanged-write discipline to LedBackendSettings' separate private12-byte LittleFS record, reusing stage/close/exact-readback/rename. Requested backend is durable before asynchronous driver admission and independent of scene/render mode/current activation success. Before driver startup initializeDefault explicitly writes a valid absent-record build default once; damaged, unmounted and no-driver records never trigger automatic repair, formatting or replacement. - Retained: Pixel-workbench checkpoint preserves validated MCU-owned records and unchanged-write avoidance while extending PixelCorrectionStore with optional per-rule enabled flags (missing means enabled), integrity-protected durable serialization and ordered skip/re-enable behavior. A revision-fenced, owner-token RAM preview lease is a Pakar-specific extension, not a claimed donor feature: expiry/clear restores the saved map, preview frames never write flash, a different editor cannot commit over the lease, and a failed same-owner save preserves the live preview until explicit retry/expiry. Diagnostics capture the original output policy so temporary clear/overlay frames cannot replace the semantic scene. - Retained: Per-physical-group pixel counts use a separate checksum-validated board-owned PixelGroupStore with stage/close/readback/rename and no-op write avoidance. Loading a missing or corrupt record never formats the filesystem or overwrites it. Host fault-injection tests cover failed persistence without applying a draft; this is not physical interrupted-flash proof. +- Retained: Reread the complete matching SettingsStore header/implementation and both actual Pakar presentation stores during source handoff. Adapt the donor independent-region/no-overlap ownership to distinct host-presentation.json/host-presentation.pending files; retain the existing validated staged transaction without migrating or overwriting the established presentation.json palette. The combined production-store regression reproduces the collision before the fix and verifies independent palette/host saves, failed rename and reboot, with correction/scene/staging bytes unchanged. - Omitted/replaced code: `Donor AVR EEPROM address/layout,14-page motion-alias normalization, relay/PWM/motion/sensor restore and temperature/name records`. Disposition: **replaced**. Reason: LED preferences live in ESP LittleFS, not the donor AVR record. Copying the address/layout or actuator settings would collide with the separate Pakar AVR owner and imply absent hardware. Applicable board settings have an independent historical manifest; this narrow mapping neither replaces it nor certifies those full current-source contracts. - Omitted/replaced code: `Donor EEPROM record ownership and complete unrelated settings normalization`. Disposition: **replaced**. Reason: The ESP scene now has its own validated bounded record,1500ms dirty coalescing,10s maximum delay, changed-record skipping and failure backoff. AVR peripheral/menu settings retain their separate EEPROM owner; copying relay/PWM/sensor layout would corrupt unrelated hardware contracts. - Omitted/replaced code: `Physical interrupted flash/power-loss durability and optical restore acceptance`. Disposition: **pending**. Reason: Actual production SceneStore fixtures now cover stage/open/short-write/close-corruption/read/rename failures, exact unchanged/frame write counts, all record-byte corruption, rollover and reboot roundtrip. These filesystem doubles and atomic LittleFS rename do not prove physical abrupt-power flash durability or emitted colors. -- Test-code evidence: `tests/led_grid_test.cpp:197 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=Boot policy accepts existing files without restoring a saved animation]`. -- Test-code evidence: `tests/s3_platform_wiring_test.mjs:145 [UTF8-LF sha256=2f224ecd6b8eade6e985bf64244690a8a93347fe6e95ab0b4646edfaa823412a; anchor=sceneStore.begin]`. -- Test-code evidence: `tests/tone_player_test.cpp:45 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const auto beforeMute]`. -- Test-code evidence: `tests/pc_contract_test.cpp:115 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentRequest[22]]`. +- Test-code evidence: `tests/led_grid_test.cpp:249 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=Boot policy accepts existing files without restoring a saved animation]`. +- Test-code evidence: `tests/s3_platform_wiring_test.mjs:148 [UTF8-LF sha256=778c0fee4df01440d52814b64363f0fba85d235edc457b4bae2739e94cf4e662; anchor=sceneStore.begin]`. +- Test-code evidence: `tests/tone_player_test.cpp:46 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const auto beforeMute]`. +- Test-code evidence: `tests/pc_contract_test.cpp:118 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentRequest[22]]`. - Test-code evidence: `desktop/src/board_silent.rs:446 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_silent_preserves_other_settings]`. - Test-code evidence: `mobile/test/board_sound_test.dart:229 [UTF8-LF sha256=e37acd59e38b8dc8696eb190f3018444352128cf0eb13ad59d125cf007cd5a45; anchor=Silent API uses actual AVR ACK/readback]`. - Test-code evidence: `tests/board_silent_test.mjs:9 [UTF8-LF sha256=95f4c3048054666df7673c618f09f57d7e2c37d34f42276b0fcd021037772825; anchor=for (const mode of []`. - Test-code evidence: `desktop/src/board_silent.rs:401 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_settings_full_apply_requires_fresh_baseline_ack_and_exact_persistent_readback()]`. - Test-code evidence: `desktop/src/board_silent.rs:299 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_settings_require_each_changed_hardware_capability_and_no_programming_latch()]`. -- Test-code evidence: `desktop/src/device_settings.rs:888 [UTF8-LF sha256=10318a349cc0e812281b6ef106460774c97a410ee0a30a6afd11ac04424d1a32; anchor=fn avr_editor_owns_dirty_draft_pending_receipt_and_same_peer_readback()]`. +- Test-code evidence: `desktop/src/device_settings.rs:950 [UTF8-LF sha256=5bce7f14b9d6225d9aaa660838b8d07f7e14bda364c520a51f1c92e83d302ed6; anchor=fn avr_editor_owns_dirty_draft_pending_receipt_and_same_peer_readback()]`. - Test-code evidence: `tests/scene_store_test.cpp:156 [UTF8-LF sha256=bde2386fcfab1043f052bcd9cf8d8ff2de2d478adfbdffd15aaa7c4a464e8a9e; anchor=Continuous edits have bounded save delay]`. - Test-code evidence: `tests/boot_audio_test.mjs:10 [UTF8-LF sha256=882dc32b024997c0411e04754bc549d0dbedf20371c4704bafc0167c657729fc; anchor=Restating the active role must ACK without resetting the winner]`. - Test-code evidence: `tests/scene_store_test.cpp:63 [UTF8-LF sha256=bde2386fcfab1043f052bcd9cf8d8ff2de2d478adfbdffd15aaa7c4a464e8a9e; anchor=MemoryFiles files;LedBackendSettings settings;]`. @@ -1774,6 +2013,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `tests/scene_store_test.cpp:48 [UTF8-LF sha256=bde2386fcfab1043f052bcd9cf8d8ff2de2d478adfbdffd15aaa7c4a464e8a9e; anchor=Diagnostics never write mode, pattern, output policy or transient RGB]`. - Test-code evidence: `tools/test-pixel-corrections.ps1:11 [UTF8-LF sha256=16ccb9491ea7c3a1abe202eccfe707c6e174635469328ea357de7acd137724e6; anchor=$taskStoreOutput]`. - Test-code evidence: `tests/pixel_group_store_test.cpp:6 [UTF8-LF sha256=45efe22ef09d0ccb1f156026a7e499bb9f593e5be5bad94b1efed199b5d8ea8b; anchor=int main()]`. +- Test-code evidence: `tests/host_presentation_test.cpp:14 [UTF8-LF sha256=f0069540e8e4861d6215156018ad8f76c0f7fa2eb172d53b8ac74766e7aa1cb3; anchor=Two production stores share LittleFS]`. - Scoped advantage: Verified semantic scene restore replaces unconditional white boot; safe initial white and read-only legacy brightness migration remain. Animation/stream bytes never become per-frame persistence writes. - Remaining: Verify guarded SDK builds and installed scene/mode/output restore across actual reboot. Physical abrupt-power interruption and optical success remain separate from host filesystem failure fixtures. - Remaining: Focused fixtures/builds are source evidence only. Physical ASUS USB/Wi-Fi, reboot persistence, muted welcome/menu/cue audio and subsequent unmute require the actual AVR; SSH8022 is currently unavailable. Full donor host routing/backend/configuration parity is not established. @@ -1787,26 +2027,26 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `desktop/src/board_silent.rs:128 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=pub struct Transaction]`. - Target: `desktop/src/ui/preferences.rs:262 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn board_controls(]`. -- Target: `mobile/lib/api.dart:1882 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future boardSound(]`. +- Target: `mobile/lib/api.dart:2109 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future boardSound(]`. - Target: `mobile/lib/board_sound_preferences.dart:10 [UTF8-LF sha256=888e518c1a85ff594b6c4c778f2eb770a4cbcad3a1d53d3defea19b5e226dc5d; anchor=class BoardSoundPreferences]`. -- Target: `firmware/esp/src/WebUi.h:3124 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=async function boardSoundTransaction(]`. -- Target: `desktop/src/ui/device_settings.rs:54 [UTF8-LF sha256=6988aa130e2f23e44b0b1fbb3f77e7ae4f221babcc4c3ed21ce0726e06c01ed3; anchor=fn avr_page(]`. -- Target: `desktop/src/device_settings.rs:646 [UTF8-LF sha256=10318a349cc0e812281b6ef106460774c97a410ee0a30a6afd11ac04424d1a32; anchor=pub fn finish_avr(]`. +- Target: `firmware/esp/src/WebUi.h:3542 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=async function boardSoundTransaction(]`. +- Target: `desktop/src/ui/device_settings.rs:54 [UTF8-LF sha256=c087cbbde3bfc194b6002b10a3b63bb16ac4e253ef33d1a6f951ab2e566a42f6; anchor=fn avr_page(]`. +- Target: `desktop/src/device_settings.rs:648 [UTF8-LF sha256=5bce7f14b9d6225d9aaa660838b8d07f7e14bda364c520a51f1c92e83d302ed6; anchor=pub fn finish_avr(]`. - Target: `libraries/BoardCore/src/PixelCorrections.h:12 [UTF8-LF sha256=2cffe251bb4f569b99cee3f3a6798f854c502cc983c57355e7947e40ce9f3c4d; anchor=bool enabled = true]`. -- Target: `firmware/esp/src/PixelCorrectionStore.h:64 [UTF8-LF sha256=2f1255aa95ec7d9cb3da57d18f3b6f43d4b95355b0cf52e57042cec643a18b9a; anchor=const char *preview(JsonObjectConst doc,uint32_t now)]`. +- Target: `firmware/esp/src/PixelCorrectionStore.h:64 [UTF8-LF sha256=2df74bce57295b7e2127aebd3f77ee52e35953162a8240c51510a6e11c1a708d; anchor=const char *preview(JsonObjectConst doc,uint32_t now)]`. - Target: `firmware/esp/src/LedScene.h:5 [UTF8-LF sha256=33e89e209eecc59de508241e51334285b767b53fb7ee0b9293ccf961e866ddd4; anchor=inline SceneState captureLedScene]`. - Retained: Reread complete donor source through EOF. Adapt MCU-owned persistent Silent bit independent of host/Windows notification audio, capability-acknowledged controls, explicit unknown/pending/verified states and full-record preservation. Native Preferences Board page uses existing PCController GET/SET/GET with real AVR ACK and persistent readback over the selected fenced HTTP or USB owner only; Web and Flutter Preferences use the same living contract. No unsupported buzzer is invented, no automatic uncertain mutation replay, and unchanged persisted flags skip EEPROM writes. - Retained: Desktop Device Settings now edits every supported Pakar AVR record field in one explicit draft: Silent/welcome/remember-page flags, display brightness, ADC6 threshold, telemetry period, short AVR name, visible/default pages and packed page order. Preserve the donor MCU ownership, validation and EEPROM wear/no-op rules, not its incompatible relay/motion/temperature record. USB and fenced HTTP share one GET/SET/GET transaction; compare the fresh complete baseline before SET, require exact ACK and persisted byte-for-byte readback, and reject stale peer/capability/programming owners without replay. - Retained: Pixel-workbench checkpoint preserves validated MCU-owned records and unchanged-write avoidance while extending PixelCorrectionStore with optional per-rule enabled flags (missing means enabled), integrity-protected durable serialization and ordered skip/re-enable behavior. A revision-fenced, owner-token RAM preview lease is a Pakar-specific extension, not a claimed donor feature: expiry/clear restores the saved map, preview frames never write flash, a different editor cannot commit over the lease, and a failed same-owner save preserves the live preview until explicit retry/expiry. Diagnostics capture the original output policy so temporary clear/overlay frames cannot replace the semantic scene. - Omitted/replaced code: `Donor relay, motion, PWM, temperature,14-page and boot restore field layouts`. Disposition: **pending**. Reason: The current hardware has a separate validated21-byte Pakar profile. Preserve all its fields and settings ownership; do not copy unrelated peripheral storage or overwrite EEPROM regions. Generic donor editors remain pending where hardware/capabilities later match. -- Test-code evidence: `tests/tone_player_test.cpp:45 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const auto beforeMute]`. -- Test-code evidence: `tests/pc_contract_test.cpp:115 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentRequest[22]]`. +- Test-code evidence: `tests/tone_player_test.cpp:46 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const auto beforeMute]`. +- Test-code evidence: `tests/pc_contract_test.cpp:118 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentRequest[22]]`. - Test-code evidence: `desktop/src/board_silent.rs:446 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_silent_preserves_other_settings]`. - Test-code evidence: `mobile/test/board_sound_test.dart:229 [UTF8-LF sha256=e37acd59e38b8dc8696eb190f3018444352128cf0eb13ad59d125cf007cd5a45; anchor=Silent API uses actual AVR ACK/readback]`. - Test-code evidence: `tests/board_silent_test.mjs:9 [UTF8-LF sha256=95f4c3048054666df7673c618f09f57d7e2c37d34f42276b0fcd021037772825; anchor=for (const mode of []`. - Test-code evidence: `desktop/src/board_silent.rs:401 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_settings_full_apply_requires_fresh_baseline_ack_and_exact_persistent_readback()]`. - Test-code evidence: `desktop/src/board_silent.rs:299 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_settings_require_each_changed_hardware_capability_and_no_programming_latch()]`. -- Test-code evidence: `desktop/src/device_settings.rs:888 [UTF8-LF sha256=10318a349cc0e812281b6ef106460774c97a410ee0a30a6afd11ac04424d1a32; anchor=fn avr_editor_owns_dirty_draft_pending_receipt_and_same_peer_readback()]`. +- Test-code evidence: `desktop/src/device_settings.rs:950 [UTF8-LF sha256=5bce7f14b9d6225d9aaa660838b8d07f7e14bda364c520a51f1c92e83d302ed6; anchor=fn avr_editor_owns_dirty_draft_pending_receipt_and_same_peer_readback()]`. - Test-code evidence: `tests/pixel_corrections_test.cpp:15 [UTF8-LF sha256=1b86a9f70dd3d79bda4e94c0eb04fcbfb38aab17aa2618a1f08ce10996501723; anchor=rules[0].enabled=false]`. - Test-code evidence: `tests/pixel_correction_store_test.cpp:31 [UTF8-LF sha256=73443a0e323ea7ab0b971d1c5605e2ef61b6c4488f3ffa9e22815997c917e008; anchor=Existing enabled-only records remain readable]`. - Test-code evidence: `tests/pixel_correction_store_test.cpp:27 [UTF8-LF sha256=73443a0e323ea7ab0b971d1c5605e2ef61b6c4488f3ffa9e22815997c917e008; anchor=files.failRename=true]`. @@ -1823,7 +2063,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp32s3/src/StatusRgb.h:162 [UTF8-LF sha256=a60111d37be51c4985e01d77821113fd0f97cd5df243e2e521b5ec3075b31b97; anchor=bool service(uint32_t now, Writer writer)]`. - Target: `desktop/src/status_rgb.rs:29 [UTF8-LF sha256=3dbe61cf121e44b14fe047427fbe9d0b8e60735baa445ad3116310560631965e; anchor=pub fn observe(]`. - Target: `mobile/lib/status_rgb.dart:52 [UTF8-LF sha256=458f867c97d38107b37fc399dfc55f1ad2f786ce5e236a9a9b2c048d0b4fbfd0; anchor=class StatusRgbPreview]`. -- Target: `firmware/esp/src/WebUi.h:2286 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function acceptStatusRgb(]`. +- Target: `firmware/esp/src/WebUi.h:2549 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function acceptStatusRgb(]`. - Retained: Full header/implementation read through EOF. Actual absolute elapsed/period evaluation, idempotent state ownership and already-scaled rendered RGB reporting adapted into the S3's board-owned semantic engine. Added bounded 300ms eased state transitions, explicit pin safety and driver-accepted output revision; no independent client-side animation reconstruction. - Retained: Native/Web/mobile now merge compact observed RGB revisions only for the selected exact board/boot. Duplicate/older/foreign revisions cannot overwrite newer RGB, renew command/hardware freshness or replace cells/layout. Complete status snapshots retain only newer same-peer RGB; peer changes discard it. Selected transport capabilities gate settings with no silent Wi-Fi fallback, and writes require saved ACK plus exact persisted readback rather than a local preview. - Omitted/replaced code: `PwmController/EEPROM/StatusProfileAddress dependencies and sixteen-bit phase clock`. Disposition: **replaced**. Reason: Those target AVR RGB PWM wiring and EEPROM allocation, not the identified S3 addressable pixel. Existing S3 persisted configuration owns enable/brightness, writer acceptance owns reported output, and the 32-bit elapsed timer advances without per-loop phase drift. @@ -1839,7 +2079,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `afbc70e3e8674c80d779168c37dc7430ab058e35baf2705b4b45cc11dd4e22ca`. - Target: `firmware/esp32s3/src/StatusRgb.h:15 [UTF8-LF sha256=a60111d37be51c4985e01d77821113fd0f97cd5df243e2e521b5ec3075b31b97; anchor=class StatusRgb]`. -- Target: `firmware/esp/application.ino:1804 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=void serviceStatusRgb(uint32_t now)]`. +- Target: `firmware/esp/application.ino:1918 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=void serviceStatusRgb(uint32_t now)]`. - Target: `firmware/esp32s3/src/StatusRgbConfig.h:14 [UTF8-LF sha256=a3f770c0448d8d4acefd31ca12c8f79a6f27218472776b4093bf5a361df97dcc; anchor=class StatusRgbConfig]`. - Target: `desktop/src/status_rgb.rs:68 [UTF8-LF sha256=3dbe61cf121e44b14fe047427fbe9d0b8e60735baa445ad3116310560631965e; anchor=pub struct Configuration]`. - Target: `mobile/lib/status_rgb.dart:116 [UTF8-LF sha256=458f867c97d38107b37fc399dfc55f1ad2f786ce5e236a9a9b2c048d0b4fbfd0; anchor=class StatusRgbSettings]`. @@ -1859,7 +2099,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp32s3/src/StatusRgb.h:242 [UTF8-LF sha256=a60111d37be51c4985e01d77821113fd0f97cd5df243e2e521b5ec3075b31b97; anchor=static uint8_t scale(]`. - Target: `desktop/src/status_rgb.rs:2 [UTF8-LF sha256=3dbe61cf121e44b14fe047427fbe9d0b8e60735baa445ad3116310560631965e; anchor=clients never synthesize]`. - Target: `mobile/lib/status_rgb.dart:9 [UTF8-LF sha256=458f867c97d38107b37fc399dfc55f1ad2f786ce5e236a9a9b2c048d0b4fbfd0; anchor=Color? statusRgbColor(]`. -- Target: `firmware/esp/src/WebUi.h:2285 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function statusRgbOutput]`. +- Target: `firmware/esp/src/WebUi.h:2548 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function statusRgbOutput]`. - Retained: Full original source read through EOF. Actual unsigned byte-exact scale(value, level) and interpolate(from, to, phase) arithmetic copied into the S3 semantic onboard-pixel engine, independent of the GPIO4 matrix. - Omitted/replaced code: `phaseDeadline distributing 64 phases in a modulo-16-bit AVR clock`. Disposition: **replaced**. Reason: S3 evaluates the present phase directly from uint32 elapsed milliseconds rather than scheduling 64 deadline steps. Late service does not slow the animation; native tests cover real production arithmetic and millis wrap, not optical output. - Test-code evidence: `tests/s3_status_rgb_test.cpp:69 [UTF8-LF sha256=f48af12963f643feca43b77ed5edd181e22981afb761305cf947d5ad053fa6f7; anchor=Already-scaled output bytes]`. @@ -1891,7 +2131,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `firmware/esp/src/HostDemand.h:9 [UTF8-LF sha256=ed625496269aacbfc22ee281879893a58d9695cad2e2338c24934a6d1018e1b8; anchor=class HostDemand]`. - Target: `firmware/esp/src/HostSerial.h:26 [UTF8-LF sha256=d7dee1d9329e3959d2e362783295de00e6345dfd66cdde3f2ee1def0856fc1da; anchor=serial.enableReboot(false)]`. - Target: `desktop/src/usb_demand.rs:84 [UTF8-LF sha256=1901dd438e454e83718feb769745bd83175a28808d746b5385d523e3c0155dac; anchor=pub struct Lease]`. -- Target: `mobile/lib/usb_transport.dart:87 [UTF8-LF sha256=b7cd007412b8f5da7491f61219c0d6020f72801506bc6732304df6666a923636; anchor=class PakarUsbTransport]`. +- Target: `mobile/lib/usb_transport.dart:87 [UTF8-LF sha256=880728e89341200cfb9c1c846e2c2407e4b105608e5b4dac0355c01b1ce2ad2c; anchor=class PakarUsbTransport]`. - Target: `libraries/BoardCore/src/SerialLink.h:26 [UTF8-LF sha256=99373bc4c090e34324cfd2ae8c94d6666c227ad7e157a47fbd85125aac6c13a0; anchor=UartFlow::receiveBudget(serial_.available())]`. - Target: `libraries/BoardCore/src/UartFlow.h:25 [UTF8-LF sha256=44f8638f725c9cf83a6f53639144f107aace1556dd254d6dcebcaed4853c6469; anchor=constexpr uint16_t serviceBudget = 64]`. - Retained: Fully read actual UART API and synchronous payload lifetime contract. Shared SerialLink keeps AVR HardwareSerial ownership, separate RX/TX frame storage and handler/opaque context; Stream constructor preserves the S3 explicit pins/buffers. @@ -1917,12 +2157,24 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `Direct donor defaults, optional boot opcodes/menu-label/audio storage and broader hardware engines`. Disposition: **pending**. Reason: Donor-constrained flash tradeoffs are not Pakar measurements. These useful feature families need explicit profile/EEPROM and memory decisions; a 328P must not blindly enable every feature or inherit donor absent-hardware behavior. - Remaining: Issue #3/#6: source-level profile mapping followed by measured affected AVR flash/SRAM/stack and driver acceptance; no new compile or commissioning in this pass. +### PCController: `README.md` + +Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `862dd06b91efdfbc09f4d895e305ecec71d84637a7bf7d4d95d907bcd3b83566`. + +- Target: `README.md:43 [UTF8-LF sha256=6ef2bde812ca8aaf889f5ad1c8313ae6e4e5ac079b93424ef4be6e2cdd007da5; anchor=## Get started]`. +- Target: `docs/README.md:7 [UTF8-LF sha256=006973cc50167b64df88e80d539604df763b3edffac561a9ada86fcc7b69683b; anchor=## Operate and recover]`. +- Retained: Task-oriented quick start, distinct surfaces, engineering-download provenance, maintained guide hub and truthful source/physical verification boundary adapted to actual Pakar contracts. +- Omitted/replaced code: `PCController hardware map, media/seat operations, Go/C ABI examples`. Disposition: **not-applicable**. Reason: Different product hardware/runtime; copying would misdirect wiring or invent Pakar commands. +- Omitted/replaced code: `Complete operator walkthrough and supported cross-platform native package matrix`. Disposition: **pending**. Reason: Pakar native packages and hardware acceptance do not match every donor target; preserve explicit platform limits rather than claim unsupported installers. +- Test-code evidence: `tests/doc_links_test.mjs:13 [UTF8-LF sha256=1f3a40d9c4fba2f5da02dc92a6637f8c073e07b10307cf5d8f405691902bba33; anchor=assert.deepEqual(checkGuides]`. +- Remaining: Expand accepted operator walkthroughs as runtime and hardware gates are completed. + ### PCController: `Tools/Bootloader/Urboot-Custom/build.mjs` Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `5e5607d54af43ead28eddb747e48b6e2450dd45ef7c47202203c4f5cfc8991ab`. - Target: `tools/prepare-avr-image.mjs:65 [UTF8-LF sha256=0d4e2a5a87967031393a6079e07938c7612e6fbfcb0d38c9a2a3ae419e86d525; anchor=export function mergeUrboot(]`. -- Target: `tools/build-firmware.ps1:266 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor='prepare-avr-image.mjs']`. +- Target: `tools/build-firmware.ps1:312 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor='prepare-avr-image.mjs']`. - Retained: Read the complete donor builder and README. Adapted actual Intel HEX/checksum parsing, AVR jump decoding/encoding, wrapped reset RJMP, ELF __bad_interrupt unused-vector proof, metadata-selected application trampoline, erased-gap fill, full-flash binary/HEX round trip and exact source hash validation into the canonical AVR build. No chip, fuse, EEPROM or UART operation is performed by packaging. - Retained: Found and corrected Pakar stock merged image reset vector bypassing its included vector Urboot. Preserve stock MiniCore UART0 autobaud, reset-register r2, EEPROM and chip-erase features with a pinned 384-byte image; never silently substitute the donor custom 512-byte backend. - Omitted/replaced code: `u8.0.1 custom 512-byte backend, progress hook and TM1637 D13/PB5 CLK, D11/PB3 DIO implementation`. Disposition: **pending**. Reason: This board uses A5 CLK/A4 DIO and the stock 384-byte application ceiling. Do not deploy a wrong-pin backend or silently shrink available application space. Adapt the backend/pin profile and revalidate flash budget before any optional custom bootloader installation. @@ -1937,7 +2189,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `2ec873e7f23b56a6997a0c83de1e258ae2ce9b75cf1eb7cf08b40da5aaa68090`. - Target: `tools/prepare-avr-image.mjs:65 [UTF8-LF sha256=0d4e2a5a87967031393a6079e07938c7612e6fbfcb0d38c9a2a3ae419e86d525; anchor=export function mergeUrboot(]`. -- Target: `tools/build-firmware.ps1:266 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor='prepare-avr-image.mjs']`. +- Target: `tools/build-firmware.ps1:312 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor='prepare-avr-image.mjs']`. - Retained: Read the complete donor builder and README. Adapted actual Intel HEX/checksum parsing, AVR jump decoding/encoding, wrapped reset RJMP, ELF __bad_interrupt unused-vector proof, metadata-selected application trampoline, erased-gap fill, full-flash binary/HEX round trip and exact source hash validation into the canonical AVR build. No chip, fuse, EEPROM or UART operation is performed by packaging. - Retained: Found and corrected Pakar stock merged image reset vector bypassing its included vector Urboot. Preserve stock MiniCore UART0 autobaud, reset-register r2, EEPROM and chip-erase features with a pinned 384-byte image; never silently substitute the donor custom 512-byte backend. - Omitted/replaced code: `u8.0.1 custom 512-byte backend, progress hook and TM1637 D13/PB5 CLK, D11/PB3 DIO implementation`. Disposition: **pending**. Reason: This board uses A5 CLK/A4 DIO and the stock 384-byte application ceiling. Do not deploy a wrong-pin backend or silently shrink available application space. Adapt the backend/pin profile and revalidate flash budget before any optional custom bootloader installation. @@ -1953,11 +2205,11 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `docs/API-POLICY.md:1 [UTF8-LF sha256=ed842b37b6ea174166559175e84947251af11d6b5cdcde026548b70a7002373a; anchor=# One living API contract]`. - Target: `tools/check-living-contract.mjs:7 [UTF8-LF sha256=28e62dedeac509186ca6009aa99c7cc5a561357f9fcda9005c5ff2308c146c1e; anchor=export function violations]`. -- Target: `desktop/src/capabilities.rs:213 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=pub fn known(]`. +- Target: `desktop/src/capabilities.rs:213 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=pub fn known(]`. - Retained: Actual living unversioned contract policy, additive optional data, stable feature IDs and strict critical identity/auth/routing/replay/deadline/integrity rejection applied across firmware and all clients. - Omitted/replaced code: `host.New/Start/Stop, Host.Call/Events and foreign-function host lifecycle ownership`. Disposition: **pending**. Reason: Pakar is ESP/AVR plus Rust/Flutter, not the Go embedded host. Importing the entire Go lifecycle without a shared-runtime adapter would create a second UART owner. Generic typed host lifecycle, notifications and library integration remain useful and pending; only the exact API policy is completed here. - Test-code evidence: `tests/living_contract_test.mjs:26 [UTF8-LF sha256=67389b8a5ef0a051959d1938d52abb6b48edeb194e8a73f51235d376c8121d59; anchor=Living-contract guard negative fixtures]`. -- Test-code evidence: `tests/web_context_test.mjs:112 [UTF8-LF sha256=e364947a8d6f93c1fc56cfbfa050f722a033ba63f24d30190238b6bfb0d95f92; anchor=Additive metadata does not select another generation]`. +- Test-code evidence: `tests/web_context_test.mjs:112 [UTF8-LF sha256=a76a645ffaca445ca0c4ec835a3e577f781b7f05a0f88d14ca326f303b2cdf02; anchor=Additive metadata does not select another generation]`. - Remaining: Compare and integrate applicable canonical host lifecycle and typed RPC/event ownership through an explicit adapter; not certified from this policy-only change. ### PCController: `Tools/Controller/internal/appconfig/buzzer_runtime.go` @@ -1966,13 +2218,13 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `desktop/src/board_silent.rs:128 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=pub struct Transaction]`. - Target: `desktop/src/ui/preferences.rs:262 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn board_controls(]`. -- Target: `mobile/lib/api.dart:1882 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future boardSound(]`. +- Target: `mobile/lib/api.dart:2109 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future boardSound(]`. - Target: `mobile/lib/board_sound_preferences.dart:10 [UTF8-LF sha256=888e518c1a85ff594b6c4c778f2eb770a4cbcad3a1d53d3defea19b5e226dc5d; anchor=class BoardSoundPreferences]`. -- Target: `firmware/esp/src/WebUi.h:3124 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=async function boardSoundTransaction(]`. +- Target: `firmware/esp/src/WebUi.h:3542 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=async function boardSoundTransaction(]`. - Retained: Reread complete donor source through EOF. Adapt MCU-owned persistent Silent bit independent of host/Windows notification audio, capability-acknowledged controls, explicit unknown/pending/verified states and full-record preservation. Native Preferences Board page uses existing PCController GET/SET/GET with real AVR ACK and persistent readback over the selected fenced HTTP or USB owner only; Web and Flutter Preferences use the same living contract. No unsupported buzzer is invented, no automatic uncertain mutation replay, and unchanged persisted flags skip EEPROM writes. - Omitted/replaced code: `Full board/host/both/none mirroring routes, runtime overrides, OS backend/executable resolution and watched app configuration`. Disposition: **pending**. Reason: Reuse the known/unknown/pending/verified board-setting ownership, not an unimplemented host audio backend. Actual host routing parity needs independent implementation and acoustic acceptance; this change must not mute Windows notifications by accident. -- Test-code evidence: `tests/tone_player_test.cpp:45 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const auto beforeMute]`. -- Test-code evidence: `tests/pc_contract_test.cpp:115 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentRequest[22]]`. +- Test-code evidence: `tests/tone_player_test.cpp:46 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const auto beforeMute]`. +- Test-code evidence: `tests/pc_contract_test.cpp:118 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentRequest[22]]`. - Test-code evidence: `desktop/src/board_silent.rs:446 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_silent_preserves_other_settings]`. - Test-code evidence: `mobile/test/board_sound_test.dart:229 [UTF8-LF sha256=e37acd59e38b8dc8696eb190f3018444352128cf0eb13ad59d125cf007cd5a45; anchor=Silent API uses actual AVR ACK/readback]`. - Test-code evidence: `tests/board_silent_test.mjs:9 [UTF8-LF sha256=95f4c3048054666df7673c618f09f57d7e2c37d34f42276b0fcd021037772825; anchor=for (const mode of []`. @@ -2046,7 +2298,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `mobile/lib/discovery.dart:177 [UTF8-LF sha256=8cb3956f80a50c5be2c134829bc7d323a3f4c58fbbcf1ea5d5e1aaa10ac7fbba; anchor=class DeviceDiscovery]`. - Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/DiscoveryChannel.kt:15 [UTF8-LF sha256=0c1215ebc188d720fe9c181a475efac7567d6b5b884de5e8d97a2d443593bd61; anchor=class DiscoveryChannel]`. -- Target: `mobile/lib/devices_page.dart:89 [UTF8-LF sha256=0604a0443c5949d8de936ddb9230a39fb8e06642f9258a18c9b9d63722513533; anchor=class DevicesPage]`. +- Target: `mobile/lib/devices_page.dart:92 [UTF8-LF sha256=73b22cde59bac6f882fd989e6494a09d3d19b010b0fc8e5ae69e55591eca44c0; anchor=class DevicesPage]`. - Target: `desktop/src/platform/lan_discovery.rs:27 [UTF8-LF sha256=870da3158a7edbf09572ff28eb7878ca4fc4d91d72b3c321a342abff6ee8bfe1; anchor=pub struct Advertiser]`. - Target: `desktop/src/bridge.rs:110 [UTF8-LF sha256=a05278f91cf4fe82a4043e4c746422f265b07d070c29c892677211db8af2b2d5; anchor=pub struct Runtime]`. - Retained: Fully read source through EOF. Adapted actual mDNS/DNS-SD single transport intent, metadata-only discovery, bounded search lifetime/cleanup, M-SEARCH headers, validated origins and explicit authentication separation into Android discovery. @@ -2091,8 +2343,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `mobile/lib/mirroring.dart:76 [UTF8-LF sha256=6fb0cc41bc30499df99ea974ae21d5b168325132bf6ae93ee961e0a5ea16b267; anchor=class ToneTimeline]`. - Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/ToneMirrorChannel.kt:22 [UTF8-LF sha256=48934806ea64dc02be68fbdf73978d65337fdb36dd94482749c14422aaf8dd20; anchor=class ToneMirrorChannel]`. -- Target: `firmware/avr/PcControllerRuntime.cpp:629 [UTF8-LF sha256=247a68576d68de47fe261f13c0d04ea5013acbf019c87ae220f7aadd0dea9d22; anchor=put32(audio + 5, s.audioStartedMicros);]`. -- Target: `firmware/avr/PcControllerRuntime.h:49 [UTF8-LF sha256=190a37d530949c34ea196e5771c5da0c02017021b1a302c5da45fda7d0b9a9ef; anchor=void companionHeartbeat]`. +- Target: `firmware/avr/PcControllerRuntime.cpp:670 [UTF8-LF sha256=7e419d2c63737a20f3bdcce8fece569e792c78471c82053cb4ed62f82582cb7b; anchor=put32(audio + 5, s.audioStartedMicros);]`. +- Target: `firmware/avr/PcControllerRuntime.h:49 [UTF8-LF sha256=0b07b3873c843b44e822be817f669ac049419c39fe2b17adeaf11f8fed626cdd; anchor=void companionHeartbeat]`. - Retained: Fully read actual current donor through EOF. Adapted uint32 MCU timestamp wrap, 50ms playout delay, 250ms skew bound, absolute note deadlines, source-generation cancellation, independent opt-in and distinct failure transitions into real Android mirroring. - Retained: Donor cadence/stop/reconnect tests adapted to a silent injectable Flutter output. Actual PCM Android renderer uses bounded 5ms buffers, exact requested Hz, click-reducing envelope, finite deadline, focus/lifecycle cancellation and an independent six-second native receive lease. - Retained: Canonical buzzer_mirror_test.go539lines fully reread through EOF; existing Flutter fixture mappings remain bounded historical adaptations, not execution of refreshed Go tests. @@ -2102,8 +2354,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `Fresh10ms/1ms recovery; scheduler handoff; stale-note rejection; no backend reprobe; same-source playback/stop/reconnect purge fixtures`. Disposition: **pending**. Reason: Current Flutter fixtures do not implement all refreshed short-note recovery/source-wide scheduler assertions. These useful regression imports remain pending with silent outputs. No donor suite or physical audio was run by this review. - Test-code evidence: `mobile/test/mirroring_transport_test.dart:58 [UTF8-LF sha256=7fb349b093ec0ff52c7e244043a5de48048d4a5108608132e97a63e416d311eb; anchor=Actual authenticated WebSocket]`. - Test-code evidence: `mobile/test/mirroring_test.dart:83 [UTF8-LF sha256=32e38d19bd9aefad76f78d921936329e12cfc1de8eed24cf704a83ea6e3f5d89; anchor=Actual donor cadence]`. -- Test-code evidence: `tests/pc_contract_test.cpp:315 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=Appended actual musical start]`. -- Test-code evidence: `tests/pc_contract_test.cpp:333 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=void companionMirroring()]`. +- Test-code evidence: `tests/pc_contract_test.cpp:318 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=Appended actual musical start]`. +- Test-code evidence: `tests/pc_contract_test.cpp:336 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=void companionMirroring()]`. - Test-code evidence: `mobile/test/mirroring_test.dart:127 [UTF8-LF sha256=32e38d19bd9aefad76f78d921936329e12cfc1de8eed24cf704a83ea6e3f5d89; anchor=Mute follow is optional]`. - Remaining: Install APK on authorized phone, deploy integrated ESP/AVR together after backups; measure welcome cadence, background/focus/long-tone/disconnect cutoff, all actual front-panel keys and segment masks. - Remaining: Complete S3 shared firmware integration, multi-source/background receiver if requested and network bridge note forwarding without echo/replay. @@ -2115,8 +2367,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `mobile/lib/mirroring.dart:76 [UTF8-LF sha256=6fb0cc41bc30499df99ea974ae21d5b168325132bf6ae93ee961e0a5ea16b267; anchor=class ToneTimeline]`. - Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/ToneMirrorChannel.kt:22 [UTF8-LF sha256=48934806ea64dc02be68fbdf73978d65337fdb36dd94482749c14422aaf8dd20; anchor=class ToneMirrorChannel]`. -- Target: `firmware/avr/PcControllerRuntime.cpp:629 [UTF8-LF sha256=247a68576d68de47fe261f13c0d04ea5013acbf019c87ae220f7aadd0dea9d22; anchor=put32(audio + 5, s.audioStartedMicros);]`. -- Target: `firmware/avr/PcControllerRuntime.h:49 [UTF8-LF sha256=190a37d530949c34ea196e5771c5da0c02017021b1a302c5da45fda7d0b9a9ef; anchor=void companionHeartbeat]`. +- Target: `firmware/avr/PcControllerRuntime.cpp:670 [UTF8-LF sha256=7e419d2c63737a20f3bdcce8fece569e792c78471c82053cb4ed62f82582cb7b; anchor=put32(audio + 5, s.audioStartedMicros);]`. +- Target: `firmware/avr/PcControllerRuntime.h:49 [UTF8-LF sha256=0b07b3873c843b44e822be817f669ac049419c39fe2b17adeaf11f8fed626cdd; anchor=void companionHeartbeat]`. - Retained: Fully read actual current donor through EOF. Adapted uint32 MCU timestamp wrap, 50ms playout delay, 250ms skew bound, absolute note deadlines, source-generation cancellation, independent opt-in and distinct failure transitions into real Android mirroring. - Retained: Donor cadence/stop/reconnect tests adapted to a silent injectable Flutter output. Actual PCM Android renderer uses bounded 5ms buffers, exact requested Hz, click-reducing envelope, finite deadline, focus/lifecycle cancellation and an independent six-second native receive lease. - Retained: Canonical buzzer_mirror.go435lines fully reread through EOF. Existing Android timeline/peer-owner mappings remain partial retained adaptations, not parity with the refreshed scheduler/backend lifecycle. @@ -2126,8 +2378,8 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `Fresh short-note/scheduler/timer rebuffering; source-wide queued-plan rebasing; original receipt-time and ready backend reuse without KDMKTONE reprobe`. Disposition: **pending**. Reason: Current mobile ToneTimeline spends age from its cushion and can discard delayed short notes; no rebaseForStart equivalent exists. Import bounded fresh-note recovery with silent fixtures without rehabilitating stale observations. Native backend reuse is useful when implemented; unrelated settings must not probe live speakers. - Test-code evidence: `mobile/test/mirroring_transport_test.dart:58 [UTF8-LF sha256=7fb349b093ec0ff52c7e244043a5de48048d4a5108608132e97a63e416d311eb; anchor=Actual authenticated WebSocket]`. - Test-code evidence: `mobile/test/mirroring_test.dart:83 [UTF8-LF sha256=32e38d19bd9aefad76f78d921936329e12cfc1de8eed24cf704a83ea6e3f5d89; anchor=Actual donor cadence]`. -- Test-code evidence: `tests/pc_contract_test.cpp:315 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=Appended actual musical start]`. -- Test-code evidence: `tests/pc_contract_test.cpp:333 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=void companionMirroring()]`. +- Test-code evidence: `tests/pc_contract_test.cpp:318 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=Appended actual musical start]`. +- Test-code evidence: `tests/pc_contract_test.cpp:336 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=void companionMirroring()]`. - Test-code evidence: `mobile/test/mirroring_test.dart:127 [UTF8-LF sha256=32e38d19bd9aefad76f78d921936329e12cfc1de8eed24cf704a83ea6e3f5d89; anchor=Mute follow is optional]`. - Remaining: Install APK on authorized phone, deploy integrated ESP/AVR together after backups; measure welcome cadence, background/focus/long-tone/disconnect cutoff, all actual front-panel keys and segment masks. - Remaining: Complete S3 shared firmware integration, multi-source/background receiver if requested and network bridge note forwarding without echo/replay. @@ -2170,7 +2422,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `30454374bc87aeed2d4a536f27a24aa8c382920e9047b40d68a42faef47990c4`. - Target: `desktop/src/bridge.rs:110 [UTF8-LF sha256=a05278f91cf4fe82a4043e4c746422f265b07d070c29c892677211db8af2b2d5; anchor=pub struct Runtime]`. -- Target: `desktop/src/main.rs:2428 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=SerialCommand::Bridge(target]`. +- Target: `desktop/src/main.rs:2448 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=SerialCommand::Bridge(target]`. - Retained: Read through EOF. Preserve primary selected owner and explicit Disconnect/revocation; sharing never opens another serial handle or acquires a board from discovery. Bridge listener starts only on opt-in and closes through owned stop lifetime. - Omitted/replaced code: `Complete host suspend/resume lifecycleInstruction service/Windows power-event integration`. Disposition: **pending**. Reason: Current bridge is scoped to the existing app runtime/connection Gate, not a new always-running service. Listener suspend/resume behavior and platform power callbacks require additional integration and actual host acceptance. - Test-code evidence: `desktop/src/bridge.rs:2179 [UTF8-LF sha256=a05278f91cf4fe82a4043e4c746422f265b07d070c29c892677211db8af2b2d5; anchor=fn bridge_network_repeater_confirms_upstream_usb_readback_and_preserves_both_hops]`. @@ -2205,14 +2457,17 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `desktop/src/bridge.rs:110 [UTF8-LF sha256=a05278f91cf4fe82a4043e4c746422f265b07d070c29c892677211db8af2b2d5; anchor=pub struct Runtime]`. - Target: `desktop/src/usb_table.rs:590 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=pub struct Intent]`. -- Target: `firmware/esp/src/PeerRelay.h:15 [UTF8-LF sha256=3f064d0547eace41857811cd3b2f2f5f4d0405904646bb814a64cd270536bc91; anchor=class PeerRelay]`. +- Target: `firmware/esp/src/PeerRelay.h:19 [UTF8-LF sha256=b59e020b680b3d726e8bed915ecf73ba8ae79d7fcaf6ce2ca435429c1582b0db; anchor=class PeerRelay]`. +- Target: `firmware/esp/src/PeerRouteStore.h:9 [UTF8-LF sha256=93e58147fbf62072c7a6ce22abe39d0dd2cf3840cd41e70bcdea8c5693889756; anchor=class PeerRouteStore]`. - Retained: Read complete source. Adapt exact request-ID correlation, ambiguous/malformed result as uncertain, consume-before-dispatch and no replay. New SerialCommand::Bridge queues through the sole existing USB owner and returns actual same-sequence ACK plus readback, not queue acceptance. - Retained: Reread source and peer_response_test.go for ESP HTTP peer relay: validate destination board/boot, exact command-result request ID and boolean acceptance; malformed/lost responses remain outcome-uncertain. Explicit target, one hop, eight bounded receipt reservations, no redirects, no automatic command retry. Worker keeps outgoing I/O out of game loop. This is not a completed mesh or physical delivery claim. +- Retained: 2026-10-28 reread complete peer_response.go and its tests. Retain strict receipt identity and uncertain/no-replay outcomes while replacing per-job task allocation and full raw history with one reusable worker and eight SHA256 fingerprints. Filter downstream JSON envelope to bound heap; read-only connection recovery never replays a POST. Explicit saved-route stage/readback/rename is loop-owned, independently of worker observations. Current and historical internal-memory metrics are distinct; no claimed cause for the historical 216-byte minimum. - Omitted/replaced code: `Generic PCController peerRPCSession JSON-RPC/WebSocket/Socket.IO response envelope`. Disposition: **pending**. Reason: Pakar uses its existing living /api/command and board/boot/deadline contract, not an unimplemented promise of generic PCController RPC compatibility. Full manager.go route/authority/transport adapters remain pending review. - Test-code evidence: `desktop/src/bridge.rs:2179 [UTF8-LF sha256=a05278f91cf4fe82a4043e4c746422f265b07d070c29c892677211db8af2b2d5; anchor=fn bridge_network_repeater_confirms_upstream_usb_readback_and_preserves_both_hops]`. - Test-code evidence: `desktop/src/usb_table.rs:842 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn actual_usb_worker_preempts_large_preview_and_confirms_compact_board_readback]`. +- Test-code evidence: `tests/peer_relay_test.cpp:140 [UTF8-LF sha256=30aa409380278617eacba57eba16589bb4e7b4671479e9c44087817882494ba2; anchor=int main()]`. - Remaining: Issue #2: Generic PCController peerRPCSession JSON-RPC/WebSocket/Socket.IO response envelope. Core native USB/network command sharing has source/socket fixtures; actual ASUS/LAN/Android/physical acceptance and full opaque firmware/binary streaming forwarding remain open. -- Remaining: ESP relay still needs automatic discovery/directory, persistent explicit trust/routes, peer event subscriptions, BLE central, USB host, ESP-NOW, multi-hop loop protection and resumable hash-verified file/firmware delivery. LAN identity pinning is not cryptographic authentication. Destination authentication is never bypassed or delegated implicitly. +- Remaining: ESP relay has explicit persistent verified route metadata and an observed directory in source; automatic discovery, continuous peer events and selectable relayed Flutter/Rust/Web transports remain open, as do BLE central, USB host, ESP-NOW and resumable hash-verified file/firmware delivery. One hop only; LAN identity pinning is not cryptographic authentication. Destination authentication is never bypassed or delegated implicitly. Hardware deployment and bidirectional latency/heap acceptance remain open. ### PCController: `Tools/Controller/internal/hostui/update_progress_test.go` @@ -2232,7 +2487,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `desktop/src/firmware_progress.rs:211 [UTF8-LF sha256=11188ec3c9bc7b89e76b94e5412f0e689fa656e5a636d4964136c2236aabbe31; anchor=pub fn observed(]`. - Target: `mobile/lib/firmware_progress.dart:8 [UTF8-LF sha256=51631d76da67f123c5419eb44d4d688c11ee162bf602ba6769e366a840d7348d; anchor=class FirmwareProgress]`. -- Target: `firmware/esp/src/WebUi.h:2437 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function firmwareUpdateObservation(s)]`. +- Target: `firmware/esp/src/WebUi.h:2700 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function firmwareUpdateObservation(s)]`. - Retained: Adapt actual explicit measured stage counters, indeterminate non-transfer stages, separate verification/completion and terminal state handling. Rust retains external board update/local OTA state in HTTP/WS/USB DTOs; Flutter consumes the existing status stream, and Web renders that same source. No transfer 100 percent is treated as completed flashing. - Omitted/replaced code: `Remaining donor mechanisms outside this delivery slice`. Disposition: **pending**. Reason: This slice does not port the donor's per-operation milestone toast tracker, Windows taskbar COM ordering or terminal OSC progress presenter. Existing native notifications remain, but complete progress-notification/OS presentation parity is pending. - Test-code evidence: `desktop/src/firmware_progress.rs:544 [UTF8-LF sha256=11188ec3c9bc7b89e76b94e5412f0e689fa656e5a636d4964136c2236aabbe31; anchor=fn firmware_progress_observes_external_ota_without_inventing_completion]`. @@ -2266,7 +2521,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `729b980122ce5e3b3024544c9229d52ce03647bf195d62958fa388304d44fbe8`. - Target: `libraries/BoardCore/src/ApiPolicy.h:8 [UTF8-LF sha256=ee7dcf1c76d2d1703ae2d8d11ab0feff19650f08e8cfddfa03faac17ba6a163c; anchor=inline bool numberedRoute]`. -- Target: `firmware/esp/src/WebAccess.h:36 [UTF8-LF sha256=314ca16a853e550f62f71211c3607e1c27d5c9c488484f7168eb27ec85e45ca2; anchor=ApiPolicy::numberedRoute]`. +- Target: `firmware/esp/src/WebAccess.h:36 [UTF8-LF sha256=774b524f4cd81c003e4139d0bb6cf3353d987674bbfb49d7a1b989ee2a232294; anchor=ApiPolicy::numberedRoute]`. - Retained: Actual TestVersionedCoreAPIRoutesAreRejected test cases adapted into the C++ helper exercised by ESP async middleware; generalized to all numbered API roots and pre-auth rejection. - Omitted/replaced code: `TestCanonicalRESTRouteInventory, TestRESTAppActionCannotInjectCoordinatorNavigationMetadata, typed app launch/action outcome lifecycle`. Disposition: **pending**. Reason: These exercise the full PCController Go host coordinator, not the ESP handlers. Applicable generic typed action/outcome/auth ownership and full real multiplexer route inventory still need explicit Pakar contracts and tests. The narrower source/wire tests here do not claim end-to-end async server acceptance. - Test-code evidence: `tests/protocol_test.cpp:79 [UTF8-LF sha256=92cd55f433424863ff59ba08b048c873c205af3133dba1bf1c9aacf5034135af; anchor=Actual donor TestVersionedCoreAPIRoutesAreRejected cases]`. @@ -2288,20 +2543,23 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `desktop/src/peer_http.rs:1110 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn local_address_cache_bounds_replaces_and_invalidates_without_ttl_or_authority]`. - Test-code evidence: `mobile/test/network_route_recovery_test.dart:11 [UTF8-LF sha256=e353a06b0fab6f9dd84cc7ce24aee1a5e8517c6e179b3975826f6adaa52f951f; anchor=void main()]`. - Test-code evidence: `mobile/test/configuration_event_transport_test.dart:9 [UTF8-LF sha256=4c48e1641b05832a0c0fd5945a9a63e080b8563515f00970f03bb7fcdee9dd08; anchor=void main()]`. +- Test-code evidence: `tests/peer_relay_test.cpp:140 [UTF8-LF sha256=30aa409380278617eacba57eba16589bb4e7b4671479e9c44087817882494ba2; anchor=int main()]`. - Scoped advantage: Adds session routing and identity-fenced durable recovery, trusted-CA TLS fixtures and no mutation replay. -- Remaining: Focused Flutter fixtures pass; actual phone/LAN, physical color and audible melody acceptance remain open. +- Remaining: Focused Flutter fixtures pass; actual phone/LAN, physical color and audible melody acceptance remain open. ESP relay/cache/route-store fixtures cover cached reuse with failed mDNS, changed DHCP address, saved-route re-verification, wrong identity/boot, private-address validation, command no-replay, memory/receipt limits, safe late DNS callbacks and interrupted filesystem publication. Actual bidirectional board latency and heap/long-soak acceptance are still pending. ### PCController: `Tools/Controller/internal/lanresolver/resolver.go` Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `8dec2b65850f9bfecf71c510f9f29392cc09def0e9590568d61ae982707cb4d7`. - Target: `mobile/lib/lan_resolver.dart:8 [UTF8-LF sha256=0dd6bae6e31632f2b526796cf4a67b5b31af978bac11d00bc932f3aef5576e92; anchor=class LanResolver]`. -- Target: `mobile/lib/api.dart:176 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=final LanResolver resolver;]`. +- Target: `mobile/lib/api.dart:181 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=final LanResolver resolver;]`. - Target: `mobile/lib/discovery.dart:116 [UTF8-LF sha256=8cb3956f80a50c5be2c134829bc7d323a3f4c58fbbcf1ea5d5e1aaa10ac7fbba; anchor=class DiscoveryCatalog]`. -- Target: `mobile/lib/devices_page.dart:70 [UTF8-LF sha256=0604a0443c5949d8de936ddb9230a39fb8e06642f9258a18c9b9d63722513533; anchor=catalog]`. +- Target: `mobile/lib/devices_page.dart:73 [UTF8-LF sha256=73b22cde59bac6f882fd989e6494a09d3d19b010b0fc8e5ae69e55591eca44c0; anchor=catalog]`. - Target: `desktop/src/peer_http.rs:339 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=pub fn device_client_builder]`. -- Target: `desktop/src/main.rs:805 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=peer_http::remember_address]`. +- Target: `desktop/src/main.rs:818 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=peer_http::remember_address]`. +- Target: `firmware/esp/src/PeerLanResolver.h:13 [UTF8-LF sha256=2413d89e26403aae1a9a528c60da09acf4ea5079067b8c1183e7e6c4d8f41546; anchor=class PeerLanResolver]`. - Retained: Actual complete donor resolver and tests read from canonical PCController; adapted successful-address caching, retaining URL hostname/HTTP Host/TLS SNI rather than replacing the public identity with an IP. +- Retained: 2026-10-28 reread resolver.go and resolver_test.go through EOF for ESP-to-ESP recovery. Adapt its reconnect-sticky successful address to a four-entry board+hostname+boot-verified cache. .local explicitly uses SDK mdns_query_a with a 900ms budget; unicast uses TCPIP-owned asynchronous IPv4 DNS and object-owned late callbacks instead of unbounded getaddrinfo. Explicit saved-route hints are reverified after reboot. HTTP Host remains the configured hostname; no cache/discovery record grants authorization or causes command replay. - Retained: Flutter HTTP and WebSocket share one resolver. TCP connect failures may trigger bounded read-only relookup but retain the last successful address; actual DNS SocketException/empty answer invalidates it. HTTP/auth/timeout failures do not evict or trigger discovery/replay. Cached route data never grants board/boot ownership. - Retained: Actual complete resolver reread at unchanged source hash for measured ASUS .local latency. A separate bounded32-host native cache accepts only actual response TCP remote_addr after valid board/boot and requested-hostname checks, retains URL Host/SNI, never trusts advertised IP and never grants command ownership. Fresh no-idle-pool clients reuse verified routing. Only typed dial/resolution failures mark a retained hint for later relookup; HTTP/auth/body timeouts do not discard it. Successful verified TCP replacement clears the bounded lookup mark; no mutation is replayed. - Retained: Actual pinned tungstenite0.30 TLS source was inspected for original-URI SNI and normal root verification. Native WS now dials numeric endpoints or the same actual-TCP cache (including verified configured DNS aliases; .local labels still match the board). A missing hint waits quietly for the existing selected HTTP worker: no duplicate WS HTTP/DNS runtime or resolver thread. TCP timeout and absolute TLS/HTTP-upgrade deadline precede handshake; selected-route revocation is checked on each IO. Both Plain and Rustls use bounded runtime reads. Actual loopback seeding/Host, empty-cache/no-socket, mismatch/revocation and deadline fixtures cover this adaptation; TLS validation and physical LAN remain separate proof. @@ -2311,6 +2569,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `mobile/test/lan_resolver_transport_test.dart:12 [UTF8-LF sha256=bf4a46ca2ce7a9639b66bd7c03f9ecd57065b58a2d7fb2cc5e8f6a0edc3f637e; anchor=void main()]`. - Test-code evidence: `mobile/test/selection_batch_network_test.dart:19 [UTF8-LF sha256=41eb7d501bf707974913ef8d1813b0e18d841b85266c032778acd15848f09dc3; anchor=test(]`. - Test-code evidence: `desktop/src/peer_http.rs:1199 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn local_verified_address_override_preserves_http_hostname_and_explicit_port]`. +- Test-code evidence: `tests/peer_relay_test.cpp:140 [UTF8-LF sha256=30aa409380278617eacba57eba16589bb4e7b4671479e9c44087817882494ba2; anchor=int main()]`. - Scoped advantage: Session-sticky routing plus hostname-first durable same-board recovery; authentication and generic HTTP failures never grant discovery or mutation replay authority. - Remaining: Verify physical Android DNS-SD/mDNS resolution, reconnect/DHCP change, TLS hostname identity and retained discovery records under real network loss. Preserve explicit Disconnect and identity fencing. @@ -2335,7 +2594,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `0148ea4aac3cf230050045756a4308157adf786a2b6ca2d4fd99bcf665a622c1`. - Target: `desktop/src/connection.rs:28 [UTF8-LF sha256=b87a15893bb66b66dbfa84ff9ffb611a40532dc679991397651267045e14e9b0; anchor=pub struct Gate]`. -- Target: `desktop/src/main.rs:2236 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=fn serial_worker(]`. +- Target: `desktop/src/main.rs:2256 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=fn serial_worker(]`. - Target: `desktop/src/usb_table.rs:101 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=pub struct Operation]`. - Retained: Lamp-background checkpoint reread actual request-admission fixture and current selected-USB worker/HTTP-isolation regression. App-only presentation IPC is separate from physical admission; selected transport, lease and no-late-write contracts remain unchanged. Inspected main.rs target/proof rebound without execution or physical claims. - Retained: Fully read all489lines through EOF. Adapted queued-intent revocation/no-late-write and ownership-before/after admission assertions as Rust selected-method epoch tests. The production Gate keeps revoked owners until their resource leases drop and never admits a competing route or a second USB handle. @@ -2345,7 +2604,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `PulseDTR cancellation/restoration/write exclusion and live physical port acquisition/late-close tests`. Disposition: **pending**. Reason: This pass intentionally adds no automatic reset and keeps AVR/ISP/audio deferred. Inactive DTR on Rust open is source configuration, not measured electrical behavior; OS acquisition/cancellation/retryable-close ownership still needs dedicated fake-handle and real-host acceptance. - Test-code evidence: `desktop/src/connection.rs:129 [UTF8-LF sha256=b87a15893bb66b66dbfa84ff9ffb611a40532dc679991397651267045e14e9b0; anchor=fn handoff_waits_for_all_actual_old_resources_and_rejects_stale_epoch()]`. - Test-code evidence: `desktop/src/connection.rs:117 [UTF8-LF sha256=b87a15893bb66b66dbfa84ff9ffb611a40532dc679991397651267045e14e9b0; anchor=fn usb_offline_never_admits_network_and_disconnect_revokes_everything()]`. -- Test-code evidence: `desktop/src/main.rs:6196 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=fn usb_selected_http_worker_never_contacts_even_a_live_network_endpoint()]`. +- Test-code evidence: `desktop/src/main.rs:6223 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=fn usb_selected_http_worker_never_contacts_even_a_live_network_endpoint()]`. - Test-code evidence: `desktop/src/usb_table.rs:842 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn actual_usb_worker_preempts_large_preview_and_confirms_compact_board_readback()]`. - Test-code evidence: `desktop/src/usb_table.rs:735 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn compact_usb_readback_validates_all_values_without_renewing_full_observations()]`. - Scoped advantage: User-required exclusive Wi-Fi-or-USB lifecycle replaces Both rather than silently falling back; physical resources retire before the new method opens. Source evidence only until guarded tests and runtime observations. @@ -2368,12 +2627,12 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `priority requestAdmission, sequence/opcode-matched ACK/Error/events, reserved macro sequence, boot HELLO retry and recoverable decoder overflow tests`. Disposition: **pending**. Reason: HTTP fencing is not downstream AVR ACK/decoder ownership. These production behaviors and exact tests remain pending with USB/UART host integration. - Omitted/replaced code: `compact HELLO schemas/build stamp and DTR reset/inactive control-line tests`. Disposition: **pending**. Reason: Build stamp is not board/boot identity; numbered public generation negotiation conflicts with living API. Adapt additive identity and explicit one-shot reset policy with serial owner, without schema selectors/reset-on-connect. - Test-code evidence: `desktop/src/terminal_ui.rs:1484 [UTF8-LF sha256=762c7ee8137a21fdbd83a40bb776238a6f75b943da35d01073bbff6b2dd64379; anchor=fn shared_cli_mutation_inherits_pinned_epoch_headers()]`. -- Test-code evidence: `tests/peer_fence_test.mjs:23 [UTF8-LF sha256=7f50838e49170c25a05ff35bd05eb730e070c4550cd9faae17850865f54c8767; anchor=Upload callbacks precede middleware]`. +- Test-code evidence: `tests/peer_fence_test.mjs:23 [UTF8-LF sha256=09291cb48a20e945aef09f5176eb9089db1bf7440fc45deb73daf9e832a456c4; anchor=Upload callbacks precede middleware]`. - Test-code evidence: `desktop/src/peer_http.rs:1590 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_preflight_reboot_or_capability_loss_never_dispatches_mutation()]`. - Test-code evidence: `desktop/src/peer_http.rs:1623 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_readback_from_new_boot_is_not_applied()]`. - Test-code evidence: `desktop/src/peer_http.rs:1682 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_unfenced_mutation_fails_before_network_and_preserves_correlation()]`. - Test-code evidence: `desktop/src/peer_http.rs:1607 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_server_boot_conflict_is_not_replayed()]`. -- Test-code evidence: `tests/web_access_fence_test.cpp:111 [UTF8-LF sha256=4bf15126c6e9ba945863583757fc8dbe521bddc850487cad08db6b80e7561743; anchor=Real async queue + real fence recheck]`. +- Test-code evidence: `tests/web_access_fence_test.cpp:121 [UTF8-LF sha256=a6d3b4d5a9fd3703c6265d093acaa21bd2cb17a30cda92ba9b4e18848e322a72; anchor=Real async queue + real fence recheck]`. - Test-code evidence: `desktop/src/usb_table.rs:842 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn actual_usb_worker_preempts_large_preview_and_confirms_compact_board_readback()]`. - Test-code evidence: `desktop/src/usb_table.rs:735 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn compact_usb_readback_validates_all_values_without_renewing_full_observations()]`. - Remaining: GUI HTTP and Web pin adoption implemented as bounded slices; native WebSocket/USB/mobile/standalone CLI/cloud fencing, remembered pairing and AVR-only boot identity remain pending; ESP32-S3 application not integrated. @@ -2384,22 +2643,22 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `1311fdcde94b2ef8ed06db63ff874a255c135dc1969709e6f43ff4b5c5c8235b`. - Target: `desktop/src/terminal_ui.rs:806 [UTF8-LF sha256=762c7ee8137a21fdbd83a40bb776238a6f75b943da35d01073bbff6b2dd64379; anchor=fn command_request(]`. -- Target: `mobile/lib/api.dart:1863 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=bool _acceptIdentity(]`. -- Target: `mobile/lib/main.dart:216 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=api.onPeerChanged = ()]`. +- Target: `mobile/lib/api.dart:2090 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=bool _acceptIdentity(]`. +- Target: `mobile/lib/main.dart:218 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=api.onPeerChanged = ()]`. - Target: `mobile/lib/bridge.dart:373 [UTF8-LF sha256=6b7fe15e223d7a110972adc698dc66e739f956f596ed265894445f333676df98; anchor=watchdog = Timer.periodic]`. - Target: `libraries/BoardCore/src/PeerFence.h:31 [UTF8-LF sha256=9f1ab63252bd881d2fc786e230a18db331e66708a1bd3ccd0f096d2d2b5d49d5; anchor=inline bool accepts(]`. -- Target: `firmware/esp/src/WebAccess.h:91 [UTF8-LF sha256=314ca16a853e550f62f71211c3607e1c27d5c9c488484f7168eb27ec85e45ca2; anchor=bool peerAllowed(]`. -- Target: `firmware/esp/application.ino:1951 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=bool authenticate()]`. +- Target: `firmware/esp/src/WebAccess.h:92 [UTF8-LF sha256=774b524f4cd81c003e4139d0bb6cf3353d987674bbfb49d7a1b989ee2a232294; anchor=bool peerAllowed(]`. +- Target: `firmware/esp/application.ino:2065 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=bool authenticate()]`. - Target: `assets/web/peer-http.js:90 [UTF8-LF sha256=9613bb23bfa007afcde525d49a193b25b93788b0f24e6a0374ce4f49560f8f9e; anchor=function assertOwner(peer)]`. - Target: `desktop/src/peer_http.rs:590 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=pub fn admit(]`. -- Target: `desktop/src/main.rs:832 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=fn scoped_http_request(]`. +- Target: `desktop/src/main.rs:845 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=fn scoped_http_request(]`. - Target: `desktop/src/usb_table.rs:571 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=pub struct Admission]`. -- Target: `mobile/lib/command_readback.dart:13 [UTF8-LF sha256=da9cc5676dbd5b744cc2a29cd080eba5bf6ad7abf9d565a738b4ca34822e1cc9; anchor=bool commandReadbackMatches(]`. +- Target: `mobile/lib/command_readback.dart:14 [UTF8-LF sha256=c920c79353178bfb46aaed6e80a467951b9131f7f3cab0653d970f6408d8703c; anchor=bool commandReadbackMatches(]`. - Target: `desktop/src/cell_batch.rs:15 [UTF8-LF sha256=e1c9e090e67ac7d1da3487a8219067d0a4bc9fd471a13d6a24f7a9462bead947; anchor=pub struct Correlation]`. -- Target: `desktop/src/realtime.rs:8 [UTF8-LF sha256=fc641c1365f2e20480f48c364ee97a20e61b39e8d850dc80a85abb128ab23ce4; anchor=pub struct Intent]`. -- Target: `mobile/lib/command_wire.dart:28 [UTF8-LF sha256=1bfce8bc4fa9ca96f6c70c9f3a1ba35d8e8a6f419ab6127e84b910feb81f50a3; anchor=String commandPacket(]`. -- Target: `libraries/BoardCore/src/CommandReceipts.h:8 [UTF8-LF sha256=86486ea6c1f770cac34e83dd69ee4477ec3e4b92992eaf1b04ec12d61539a092; anchor=class CommandReceipts]`. -- Target: `mobile/lib/table_workspace.dart:13 [UTF8-LF sha256=1f21007e69778ddbedb34a1c392a152693f5dd742aae185019e6dbc1dc2b08a5; anchor=class TableWorkspace]`. +- Target: `desktop/src/realtime.rs:8 [UTF8-LF sha256=134977c4f18998b92ebb2e8d763147a1c0eeb2039c019a603cb7de712502fa01; anchor=pub struct Intent]`. +- Target: `mobile/lib/command_wire.dart:49 [UTF8-LF sha256=5b60bf4d43874d5ef071dbc118cbbc581c2c59d1af8491116aba56570298a3cb; anchor=String commandPacket(]`. +- Target: `libraries/BoardCore/src/CommandReceipts.h:8 [UTF8-LF sha256=061d79642fdeb1e07f528a63438ba466c3784fc96aef7ff9ca881508675dd2dc; anchor=class CommandReceipts]`. +- Target: `mobile/lib/table_workspace.dart:13 [UTF8-LF sha256=02445683146ff2b3bdca50bfa8d1aa1dedb94261c361613fdb545e7ca4f7c83e; anchor=class TableWorkspace]`. - Target: `libraries/BoardCore/src/BleWire.h:31 [UTF8-LF sha256=e6b8a769227fed14658e25d2f21be2773e2607902abd79cf91cd03eda22c7230; anchor=class Receiver]`. - Target: `firmware/esp32s3/src/BleService.h:71 [UTF8-LF sha256=7274cd7a20d0a67ca68e924b2792778d9a86ece8ce837a0a1fa518d744a67b94; anchor=class BleService]`. - Target: `firmware/esp32s3/src/BleStorage.h:21 [UTF8-LF sha256=1b0b11d0d1f7f398699629ccdbd32d11e3308aef5f738f5963fcfd1d176d81e8; anchor=class BleBulkOwner]`. @@ -2440,22 +2699,22 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `desktop/src/terminal_ui.rs:1136 [UTF8-LF sha256=762c7ee8137a21fdbd83a40bb776238a6f75b943da35d01073bbff6b2dd64379; anchor=fn same_hostname_reboot_revokes_drafts_and_old_receipts()]`. - Test-code evidence: `desktop/src/peer_identity.rs:504 [UTF8-LF sha256=18eb925ae24b4f69e08205c33f12a798d73020313fcf4311d51a9118042f0979; anchor=fn usb_introduction_error_preserves_actual_board_reason_without_suggesting_policy_bypass()]`. - Test-code evidence: `mobile/test/bridge_discovery_test.dart:37 [UTF8-LF sha256=1e650a1f6897c67ce9e87de864ff28ed98e0a818ca95ba6b413897b5cd99a905; anchor=Same-host board/boot replacement retires firmware owners synchronously]`. -- Test-code evidence: `tests/peer_fence_test.mjs:23 [UTF8-LF sha256=7f50838e49170c25a05ff35bd05eb730e070c4550cd9faae17850865f54c8767; anchor=Upload callbacks precede middleware]`. +- Test-code evidence: `tests/peer_fence_test.mjs:23 [UTF8-LF sha256=09291cb48a20e945aef09f5176eb9089db1bf7440fc45deb73daf9e832a456c4; anchor=Upload callbacks precede middleware]`. - Test-code evidence: `tests/web_peer_http_test.mjs:24 [UTF8-LF sha256=db715ef84572ee0279db89cdb3bc5700bbd6e7ebe206a8fdedf335b60d60d048; anchor=Same-host reboot between draft creation and preflight]`. - Test-code evidence: `desktop/src/peer_http.rs:1640 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_revocation_during_preflight_stops_before_write()]`. - Test-code evidence: `desktop/src/peer_http.rs:1660 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_revocation_after_dispatch_reports_uncertainty_not_success()]`. -- Test-code evidence: `desktop/src/main.rs:874 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=// The mapping response identifies its own producer.]`. +- Test-code evidence: `desktop/src/main.rs:887 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=// The mapping response identifies its own producer.]`. - Test-code evidence: `desktop/src/peer_http.rs:1442 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_mapping_enveloped_read_needs_no_http_status_preflight()]`. - Test-code evidence: `desktop/src/peer_http.rs:1459 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_mapping_foreign_or_previous_boot_response_is_never_adopted()]`. - Test-code evidence: `desktop/src/peer_http.rs:1481 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_mapping_incomplete_or_invalid_envelope_is_not_legacy()]`. - Test-code evidence: `desktop/src/peer_http.rs:1504 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_mapping_revoked_inflight_result_is_not_applied()]`. - Test-code evidence: `desktop/src/peer_http.rs:1521 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_mapping_expired_queue_does_not_contact_device()]`. - Test-code evidence: `desktop/src/peer_http.rs:1534 [UTF8-LF sha256=4e824caea5ef7bea0b2fc81af1ba1571bb7f3374582c3d641223cf0ce20cae50; anchor=fn gui_mapping_without_envelope_retains_both_legacy_status_fences()]`. -- Test-code evidence: `tests/web_access_fence_test.cpp:111 [UTF8-LF sha256=4bf15126c6e9ba945863583757fc8dbe521bddc850487cad08db6b80e7561743; anchor=Real async queue + real fence recheck]`. +- Test-code evidence: `tests/web_access_fence_test.cpp:121 [UTF8-LF sha256=a6d3b4d5a9fd3703c6265d093acaa21bd2cb17a30cda92ba9b4e18848e322a72; anchor=Real async queue + real fence recheck]`. - Test-code evidence: `desktop/src/usb_table.rs:1539 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn table_write_is_exact_boot_fenced_and_post_ack_values_are_not_assumed()]`. - Test-code evidence: `mobile/test/connection_lifecycle_test.dart:254 [UTF8-LF sha256=7c3e281d1e98ee1cf49e9116a0cc8c85a9adcc33683992d225b6258dcd94bce4; anchor=Lost post-ACK readback stops bulk without discarding a fresh workspace]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1866 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=Even identical values require the exact operation ID]`. -- Test-code evidence: `desktop/src/realtime.rs:584 [UTF8-LF sha256=fc641c1365f2e20480f48c364ee97a20e61b39e8d850dc80a85abb128ab23ce4; anchor=fn realtime_actual_ws_worker_delivers_receipt_without_http_or_poll_tick()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1866 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=Even identical values require the exact operation ID]`. +- Test-code evidence: `desktop/src/realtime.rs:786 [UTF8-LF sha256=134977c4f18998b92ebb2e8d763147a1c0eeb2039c019a603cb7de712502fa01; anchor=fn realtime_actual_ws_worker_delivers_receipt_without_http_or_poll_tick()]`. - Test-code evidence: `mobile/test/live_realtime_test.dart:8 [UTF8-LF sha256=6af0113cf86b6f20fc9e28405bf7fbeb26a66805b5cd12f9bdb4ba39571a00f8; anchor=Explicit opt-in, real board mutation]`. - Test-code evidence: `mobile/test/realtime_transport_test.dart:11 [UTF8-LF sha256=ef8a5a39abe37c40e3393cdde5b3f012dbc5a7adfc2e2cd104571ca04cd5de7e; anchor=Real loopback transport]`. - Test-code evidence: `mobile/test/table_layout_test.dart:59 [UTF8-LF sha256=254598b19cab81a963e6319ddb9acf63675d22d8d6f451211beef89da2367abd; anchor=Cell commands and incoming state preserve both scroll axes and canvas position]`. @@ -2466,7 +2725,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Test-code evidence: `desktop/src/usb_table.rs:735 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn compact_usb_readback_validates_all_values_without_renewing_full_observations()]`. - Test-code evidence: `desktop/src/usb_table.rs:1411 [UTF8-LF sha256=13c191e37a4a881d96fb668c0ccb99fa2f168955e6abb78bb37940a629434042; anchor=fn actual_usb_worker_windows_read_latency_does_not_expire_large_capture]`. - Test-code evidence: `tests/ble_wire_test.cpp:15 [UTF8-LF sha256=bf5675db9debf06160e8ef72d59e7c764bc3f88cde508cfcbb32a989f004c34e; anchor=int main()]`. -- Test-code evidence: `tests/ble_firmware_test.mjs:109 [UTF8-LF sha256=4ac033c437cc296bab12e6921e5ec237768c477941e34e73d8c7ca362314291f; anchor=BLE firmware integration source guards]`. +- Test-code evidence: `tests/ble_firmware_test.mjs:124 [UTF8-LF sha256=8359be8eb6c8d752a3db69ccba881a548428fd26b0ba2a1e189c2ec8058997bc; anchor=BLE firmware integration source guards]`. - Test-code evidence: `tests/ble_storage_test.cpp:51 [UTF8-LF sha256=22f4c0e4fc0846492ddaa6c04a01e113ace607d86e9edeac1d2c69b8ee46a349; anchor=Do not retain an internal bulk-buffer fallback]`. - Test-code evidence: `tests/ble_storage_stubs/esp_heap_caps.h:18 [UTF8-LF sha256=f6ff5f7843125cd7e780df39fd9d31006af9ef23070641ec580288235750365c; anchor=assert(capabilities==(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT))]`. - Test-code evidence: `services/remote-panel/gateway.test.mjs:87 [UTF8-LF sha256=2fdbab7b76e05249f07d126b9f655a4ff909a59291ec924d8eb2b6ec4f8455f6; anchor=Real HTTP mutation receives one board ACK/readback]`. @@ -2493,7 +2752,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Target: `tools/benchmark-led-api.mjs:8 [UTF8-LF sha256=67c184a28a6450dd81f41576ab7f1582092e3c151d4d0af6e9d8415d5f4c3d7f; anchor=export async function runLedApiBenchmark]`. - Target: `tools/check-ethernet-live.mjs:70 [UTF8-LF sha256=29e5e90657ea68415c49f947e09561a8750f7998b972e88d689bc6b79c82b08c; anchor=export async function checkEthernet(]`. -- Target: `libraries/BoardCore/src/PeerRoutePolicy.h:9 [UTF8-LF sha256=079ccdb652af2f7138312fa6cfe8ae5906d325adc9a9deea3911708fe550f394; anchor=inline bool hostname]`. +- Target: `libraries/BoardCore/src/PeerRoutePolicy.h:9 [UTF8-LF sha256=4b785df2ec881c20c3c89cb9e0e88811f0c74b6859608f1fa880b54b6f5f226b; anchor=inline bool hostname]`. - Retained: Complete actual source read. Operator-configured HTTP authority validation, credential/fragment/whitespace/backslash rejection and bounded port/hostname checks adapted to the explicit live LED/API benchmark. - Retained: Reconsulted full canonical source for the Ethernet live probe. Adapts operator-configured hostname authority checks to a bounded no-proxy/no-redirect HTTP probe with actual TCP peer pinning, board/boot fencing and exactly one optional ESP-only stage request; an unknown mutation result is never replayed. - Retained: ESP peer relay adapts strict hostname parsing and explicit LAN scope: reject credentials, ports, paths, numeric hosts, link-local/loopback/public resolved addresses. Pin the resolved address across identity and actual request. Restricted GET allowlist and correlated command endpoint exclude recursive relays, raw secrets and flash writes. @@ -2530,7 +2789,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `520de1d7431f3062d0603636b89098d1e41c7701a2f44f65b88d45cfe49a2c07`. -- Target: `firmware/esp/src/MaintenanceRoutes.h:293 [UTF8-LF sha256=3e2ca1562541eea7218a6740c0ac9d2eba319072674fc0e0a3e86110a292adfc; anchor=bool prepareRemotePair(]`. +- Target: `firmware/esp/src/MaintenanceRoutes.h:298 [UTF8-LF sha256=cc32c9d0b953c94e9a7bc8677cf171ea1a7695edc373e7f6911667db21f54abc; anchor=bool prepareRemotePair(]`. - Target: `firmware/esp/src/UrbootRoutes.h:90 [UTF8-LF sha256=e0d620507febba322f8c63bee60be7e353123dfc16d55c83bc7060178c759316; anchor=bool runUrboot(]`. - Retained: Read entire actual donor through EOF. Preserve complete backup before production writes, cancellation before mutation and post-backup candidate reinspection. The network bridge forwards the existing guarded ESP coordinator's raw bytes/status/receipts; it does not replace the programmer or turn staging acceptance into a completed flash. - Retained: Explicit Urboot EEPROM write/initialization extends this same backup-before-write boundary: SHA-pinned full image, confirmed request, separate retained request directory, unchanged-chunk avoidance and readback without write retries. Bundled AVR recovery only stages an image; existing ISP signature, snapshot and fuse guards remain mandatory. Physical EEPROM and blank-MCU fuse provisioning acceptance remain open. @@ -2544,7 +2803,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `6dabd79c406b49dce87866fa792bc557220b98515606041cc33099193d567abe`. -- Target: `firmware/esp/src/MaintenanceRoutes.h:293 [UTF8-LF sha256=3e2ca1562541eea7218a6740c0ac9d2eba319072674fc0e0a3e86110a292adfc; anchor=bool prepareRemotePair(]`. +- Target: `firmware/esp/src/MaintenanceRoutes.h:298 [UTF8-LF sha256=cc32c9d0b953c94e9a7bc8677cf171ea1a7695edc373e7f6911667db21f54abc; anchor=bool prepareRemotePair(]`. - Retained: Read complete actual donor tests. Retain failure/no-flash, unchanged candidate and EEPROM-reset raw-backup-before-arm distinctions in the existing coordinator review. New actual socket tests cover stale peer before dispatch, valid multipart/binary transfer, HEAD and truncation; these do not claim execution of the donor's Go programmer tests or physical backup acceptance. - Omitted/replaced code: `Host Urclock/USBasp executor, backup archive manifest and development skip policy`. Disposition: **pending**. Reason: Retain the existing ESP-owned integrated ISP coordinator and mandatory production backup rather than adding a competing serial/programming owner. Host archival restore/executor parity needs a separate full dependency and hardware review; it is not claimed by transparent HTTP forwarding. - Test-code evidence: `desktop/src/bridge_transfer.rs:377 [UTF8-LF sha256=fbb13202d86296f1766d0e20bcd9e4a520a8b13d4988890e5a5273a20a5469fe; anchor=fn bridge_transfer_head_truncation_and_stale_mutation_preserve_truth]`. @@ -2615,9 +2874,9 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `de67f2ac9dd8037e354b98227616ed8c207379dbc8205692587bc07a6392be83`. - Target: `assets/web/device-settings.js:165 [UTF8-LF sha256=28b490c94a63c0b55df4cac22405b02d9ef43725ee08d5e9e3cc418ae8df5492; anchor=function panelToggle(]`. -- Target: `assets/web/feature-workspaces.js:178 [UTF8-LF sha256=e3ee781bcd00a2f84a06f893fa224244b8d555b7b9d956edc5cfe30588aa17c0; anchor=function reveal(target)]`. +- Target: `assets/web/feature-workspaces.js:180 [UTF8-LF sha256=93f24fd4008ef69eb247dd02d084a531a04b1dffaa86d6b651df43b5b6c3f33a; anchor=function reveal(target)]`. - Target: `assets/web/device-settings.js:166 [UTF8-LF sha256=28b490c94a63c0b55df4cac22405b02d9ef43725ee08d5e9e3cc418ae8df5492; anchor=function renderPanelMenu(]`. -- Target: `assets/web/workspace-studio.js:87 [UTF8-LF sha256=bdc455a67af4c9803cd9ea76c725d800b8bdca2e98c831968b1b5a39840266e8; anchor=for(const[id,label]of pages)]`. +- Target: `assets/web/workspace-studio.js:87 [UTF8-LF sha256=c5c4b999f9fb7ca6011b9193a1ded10ed293bba8b53d9c861ad1ea4c108a15f4; anchor=for(const[id,label]of pages)]`. - Target: `assets/web/studio.css:105 [UTF8-LF sha256=4d87d4cc97b3a53b2144912cd0c123ebc21b9b7677bdb02e6901a6c695e36e22; anchor=--square-size:34px]`. - Target: `docs/INTERFACE-CONTRACT.md:51 [UTF8-LF sha256=37a0aec859267e0fd62b546ffc044eaa02ae04d2f9a298c5d94a5c24c803cb1b; anchor=## Known useful parity gaps, not accepted exclusions]`. - Retained: Actual CardLayoutEditor hidden-card show/reset and CardLayoutFrame hidden/collapsed actions adapted to the embedded workspace Panels dropdown and panel headers. Persisted preferences skip identical writes; lost capability overrides visibility. @@ -2627,7 +2886,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Omitted/replaced code: `CardLayoutCopy localization and Lucide Check/ChevronDown/Eye/EyeOff/GripVertical/RotateCcw toolbar glyphs`. Disposition: **pending**. Reason: Show/Hide/Reset, collapse and sidebar glyph/localized accessible-name consumers are now adapted with the established vector catalog. Remaining edit/reorder controls, all CardLayoutCopy consumers and their keyboard/pointer focus behavior are not certified by this pass; no React/Lucide runtime was added. - Omitted/replaced code: `React generic component props and ReactPointerEvent/CSSProperties wrappers`. Disposition: **replaced**. Reason: The ESP serves a standalone embedded document, not the donor React runtime. Existing live DOM cards retain their handlers when settings cards are reparented; no duplicate React state is introduced. - Test-code evidence: `tests/web_control_presentation_test.mjs:74 [UTF8-LF sha256=f563ad7b87d95fdb08b84737436774bfc4d21a709678007287d25706f05c2272; anchor=Collapsed rail actions retain localized accessible names]`. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:102 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Injected navigation must not be followed by a second targetless fallback]`. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:103 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Injected navigation must not be followed by a second targetless fallback]`. - Test-code evidence: `tests/web_control_presentation_test.mjs:79 [UTF8-LF sha256=f563ad7b87d95fdb08b84737436774bfc4d21a709678007287d25706f05c2272; anchor=assert.equal(menu.children[0].getAttribute('aria-checked'),'true')]`. - Test-code evidence: `tests/interface_settings_test.mjs:123 [UTF8-LF sha256=102d6c411b732bad8106dd09358da3df3989d1140aa77c87a884c8072c66c6f3; anchor=Capability loss must override stored panel visibility]`. - Remaining: Complete edit-mode panel ordering, remaining edit/reorder icon/localization consumers and pointer/keyboard ownership tests. The named menu/rail/collapse glyphs are implemented; source/HTTP doubles and browser fixtures are not physical acceptance. @@ -2636,9 +2895,9 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `c1784ab6b851560d39b910df86ec3690f86d90895847ebdc352061a28c36082d`. -- Target: `web/modules/components.js:71 [UTF8-LF sha256=84a86483502833830a8b60c2eb4f1c47945807252e92bdaa3de0b7f7dc0c8151; anchor=function decorateControl(]`. -- Target: `web/modules/components.js:88 [UTF8-LF sha256=84a86483502833830a8b60c2eb4f1c47945807252e92bdaa3de0b7f7dc0c8151; anchor=function setControlText(]`. -- Target: `assets/web/localization.js:43 [UTF8-LF sha256=2553d87375244b6de1e8ed82e63978237d990c5736aa848e66f53a7ac7849f83; anchor=function renderUiText(]`. +- Target: `web/modules/components.js:73 [UTF8-LF sha256=200e38d3a70594f6f2463b03bc74df53cc10e17e8d2264c7851c200d9beff7d4; anchor=function decorateControl(]`. +- Target: `web/modules/components.js:90 [UTF8-LF sha256=200e38d3a70594f6f2463b03bc74df53cc10e17e8d2264c7851c200d9beff7d4; anchor=function setControlText(]`. +- Target: `assets/web/localization.js:43 [UTF8-LF sha256=fc7a39cae41e490fba858a8c7129abf0a7064811c486ddf9ba1b115b79e7c0fb; anchor=function renderUiText(]`. - Retained: The Web owner fully read all 992 lines of the actual C:/Users/David/AppData/Local/Programs/PCController/source/PCController/Tools/Controller/web/src/components.tsx at the matching inventory revision. Adapt Button/Icon's separate decorative glyph and visible label, explicit SVG dimensions/currentColor stroke, meaningful accessible names and disabled behavior into existing DOM controls. decorateControl is idempotent and preserves existing action handlers and localizer-owned text nodes; setControlText/renderUiText update labels without destroying glyphs or leaving stale accessible names. Cell/box controls and peer-provided labels are not guessed into generic action icons. - Retained: Dependency styling was reviewed only at donor styles.css lines 379-415 and 625-632, SHA256 6dee0982d74816eeae95efbeeaea25326272f231906e1771a5ba4266a81b7efa7d. Stable borders, min-width/flex containment and optical SVG alignment informed the current button slots. This does not certify the rest of that CSS source as read or imported. - Omitted/replaced code: `React component runtime and typed component props`. Disposition: **replaced**. Reason: The single canonical embedded Web application already owns DOM handlers and localization; retain those owners rather than adding a second React runtime and duplicate state. @@ -2665,32 +2924,32 @@ Verdict: **equivalent-behavior**. Revision: `b0111ae0c80dc34149ea1044de7dd548892 Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `a5d7366279057e0868478846c6bbe92eda4209a6bdbcbc16e1c149e57dfe035e`. -- Target: `tests/web_context_test.mjs:37 [UTF8-LF sha256=e364947a8d6f93c1fc56cfbfa050f722a033ba63f24d30190238b6bfb0d95f92; anchor=const manifest=]`. -- Target: `mobile/test/peer_context_test.dart:46 [UTF8-LF sha256=6e6e31c57e98592835578476b0110d4e527d60da17f224153e9e0ae9b81c8b86; anchor=No peer ACK cannot be inferred]`. +- Target: `tests/web_context_test.mjs:37 [UTF8-LF sha256=a76a645ffaca445ca0c4ec835a3e577f781b7f05a0f88d14ca326f303b2cdf02; anchor=const manifest=]`. +- Target: `mobile/test/peer_context_test.dart:87 [UTF8-LF sha256=e0dae66acf441701c737fc336ba24c4905d1f0ae68b889ee8a976390ed41fec8; anchor=No peer ACK cannot be inferred]`. - Target: `mobile/test/widget_test.dart:113 [UTF8-LF sha256=6a210785ac66f598f4772a28afa9bfb6c69379c344d5c4e08b24d1628af215c9; anchor=Unacknowledged board has no Table]`. - Target: `mobile/test/hardware_page_test.dart:50 [UTF8-LF sha256=dbfb3b22b427821e3ff621ad53f1acd413697fbf3fb0ac848f3807a89ee9441b; anchor=Hardware owns every acknowledged capability exactly once]`. -- Target: `tests/device_context_test.cpp:4 [UTF8-LF sha256=641d5b37785aed48b4e3b141735622e9c8463a8127e929b17ce4c8a2844a5265; anchor=int main(]`. -- Target: `desktop/src/capabilities.rs:644 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=fn serial_manifest_is_individual_and_table_modes_are_profile_scoped]`. +- Target: `tests/device_context_test.cpp:4 [UTF8-LF sha256=60d085c8a02ea52a3aa88875f24e22387ef9dd41415f9847127207fad0e535a3; anchor=int main(]`. +- Target: `desktop/src/capabilities.rs:646 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=fn serial_manifest_is_individual_and_table_modes_are_profile_scoped]`. - Retained: Actual disconnected, missing status and capability-free cases adapted into shipped-Web tests. Independent peripheral ACKs, expiry, cross-kind and malformed geometry/schema cases added. C++ tests execute actual HELLO/display-ACK policy. - Retained: Equivalent Flutter fixture cases execute actual capability parsing, unsupported schema/geometry, display measured ACK, per-feature cards, identity change and phone/tablet navigation; API fixtures reject pending reads/queued writes after owner or credential changes. - Retained: Flutter hardware-page regressions move capabilities from Overview into Hardware, retain dimmed board-scoped badges without granting fresh ACK authority, expose capability-only profiles without requiring network telemetry, and cover phone/tablet text scaling. Existing selected-peer parsing and PanelInventory remain the sole data owners. - Omitted/replaced code: `Vitest/liveSnapshot/emptySnapshot fixture ownership`. Disposition: **replaced**. Reason: Node VM executes embedded Web code, C++ executes firmware policy, and Rust has USB adapter tests. React/Vitest would target a nonexistent React owner in this embedded surface. - Omitted/replaced code: `Valid/invalid INA219, temperature, PWM, LCD, Bluetooth, relay/RF/statusLED fixture cases`. Disposition: **pending**. Reason: Restore exact bounded-invalid-value cases with the genuine PCController adapter; those peripherals are currently absent, not replaced by synthetic zero fixtures. -- Test-code evidence: `tests/web_context_test.mjs:110 [UTF8-LF sha256=e364947a8d6f93c1fc56cfbfa050f722a033ba63f24d30190238b6bfb0d95f92; anchor=Unsupported geometry is not forced]`. -- Test-code evidence: `tests/device_context_test.cpp:4 [UTF8-LF sha256=641d5b37785aed48b4e3b141735622e9c8463a8127e929b17ce4c8a2844a5265; anchor=int main(]`. +- Test-code evidence: `tests/web_context_test.mjs:110 [UTF8-LF sha256=a76a645ffaca445ca0c4ec835a3e577f781b7f05a0f88d14ca326f303b2cdf02; anchor=Unsupported geometry is not forced]`. +- Test-code evidence: `tests/device_context_test.cpp:4 [UTF8-LF sha256=60d085c8a02ea52a3aa88875f24e22387ef9dd41415f9847127207fad0e535a3; anchor=int main(]`. - Remaining: Rust tests remain cache-budget blocked; generic adapter sensor/profile tests and physical/native acceptance remain #10. ### PCController: `Tools/Controller/web/src/peripheral-availability.ts` Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `fcf2e397564c843711fd64db2bd2d820552180ac4f5abdf0d44ccbbb86be7a84`. -- Target: `desktop/src/capabilities.rs:225 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=pub fn has(]`. -- Target: `mobile/lib/peer_context.dart:50 [UTF8-LF sha256=e2d91e078d6dad7454eaa68f9a030ef1aa8ae2c91ba72ba522818da6b74131b8; anchor=static PeerContext fromStatus]`. +- Target: `desktop/src/capabilities.rs:225 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=pub fn has(]`. +- Target: `mobile/lib/peer_context.dart:54 [UTF8-LF sha256=c8602a12ccd3009028068aba091d46900c8aa0e345a4426ffe80acfb716362fc; anchor=static PeerContext fromStatus]`. - Target: `mobile/lib/cell_pixel_preview.dart:12 [UTF8-LF sha256=b02d26cea9854561864beeab59c1576a32e31af1bb42cd5b8c42d196e6f7bc1b; anchor=CellPixelPreview? cellPixelPreview]`. -- Target: `mobile/lib/main.dart:1049 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=Future _command]`. +- Target: `mobile/lib/main.dart:1052 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=Future _command]`. - Target: `libraries/BoardCore/src/DeviceContext.h:25 [UTF8-LF sha256=d1fe9e5a3663b13017ea61d251b6bc314ce91b2cb43b8c8b9c5308fe5fa73f1c; anchor=inline uint16_t features(]`. -- Target: `firmware/esp/application.ino:2422 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=if(BoardCore::Context::pixelEvents(available))]`. -- Target: `firmware/esp/src/WebUi.h:2251 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function peerAvailability(]`. +- Target: `firmware/esp/application.ino:2459 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=if(BoardCore::Context::pixelEvents(available))]`. +- Target: `firmware/esp/src/WebUi.h:2511 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function peerAvailability(]`. - Retained: Actual connected AND have_status AND advertised-capability conjunction adapted to selected-peer fresh manifests. No feature inference from hostname, pixel count, catalog, defaults or online alone. - Retained: Reread the complete unchanged availability donor and tests for the table live-preview regression. Shared Context::pixelEvents now requires actual Network plus Leds service availability for both compiled roles, without requiring AVR acknowledgement. The same predicate owns feature advertisement, subscription admission and publication; no-driver and network-disabled builds cannot advertise or start pixel events. Existing authorized-listener-only latest-frame coalescing and backpressure remain unchanged. Both role host fixtures and source integration guards passed; live device subscription and optical acceptance remain separate. - Retained: Reread the complete unchanged donor and actual Flutter peer parser/menu/tab consumers for missing Pixel corrections: the known-feature allowlist now retains the explicit pixel-corrections advertisement. Unsupported or mismatched-owner manifests still cannot expose it; existing linked Diagnostics/Corrections tabs are reused rather than duplicated. Actual overflow navigation fixtures assert supported/unsupported visibility and zero board writes. @@ -2700,13 +2959,13 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O - Retained: Reread the actual availability implementation/tests and Pealayer board information UI. Add optional board-owned LED backend/RMT/DMA capability data to native, Flutter and Web without deriving it from chip model, output enabled or renderer mode. Missing/mistyped/contradictory metadata remains unknown; native serialization and USB compact readback retain the full manifest. Existing driver observation separately owns readiness/activity/faults. - Omitted/replaced code: `Snapshot/status/hello fields and compact status flag fallback`. Disposition: **replaced**. Reason: Pakar JSON and append-only USB share a schema-1 normalized manifest. AVR HELLO and display-status ACK are aggregated on ESP; copying PCController bit values would alias different protocols. - Omitted/replaced code: `bounded, ina219, pwm, temperatureLED, temperatureBTAudio, lcd, bluetoothAudio, relays, rf, statusLED and invalid measurement fields`. Disposition: **pending**. Reason: Those drivers are absent on the current pair. A real PCController adapter must preserve their detection, bounds and invalid-value distinctions later. No fabricated controls are displayed. -- Test-code evidence: `tests/web_context_test.mjs:61 [UTF8-LF sha256=e364947a8d6f93c1fc56cfbfa050f722a033ba63f24d30190238b6bfb0d95f92; anchor=No inferred board kind or controls]`. +- Test-code evidence: `tests/web_context_test.mjs:61 [UTF8-LF sha256=a76a645ffaca445ca0c4ec835a3e577f781b7f05a0f88d14ca326f303b2cdf02; anchor=No inferred board kind or controls]`. - Test-code evidence: `mobile/test/cell_pixel_preview_test.dart:51 [UTF8-LF sha256=273d653de88c159c6e5019fb1e3b3df7d700e3c5b8146bcf5293f728b19f1ad4; anchor=Board diagnostic overrides stale white semantics using physical mapping]`. -- Test-code evidence: `mobile/test/pixel_tools_navigation_test.dart:64 [UTF8-LF sha256=a865e8b0f37143c687ca1fbfeffe2f5d12f1bbbe08949f34bd97e488faea16e0; anchor=Actual overflow menu opens capability-scoped linked pixel tabs: corrections=$supported]`. -- Test-code evidence: `mobile/test/peer_context_test.dart:62 [UTF8-LF sha256=6e6e31c57e98592835578476b0110d4e527d60da17f224153e9e0ae9b81c8b86; anchor=Independent ACKs and measured display gate]`. +- Test-code evidence: `mobile/test/pixel_tools_navigation_test.dart:64 [UTF8-LF sha256=ff0558b0f9240f4b2f5ad83121ff0423c2e8bbef83a6025a424897c35fee1408; anchor=Actual overflow menu opens capability-scoped linked pixel tabs: corrections=$supported]`. +- Test-code evidence: `mobile/test/peer_context_test.dart:103 [UTF8-LF sha256=e0dae66acf441701c737fc336ba24c4905d1f0ae68b889ee8a976390ed41fec8; anchor=Independent ACKs and measured display gate]`. - Test-code evidence: `mobile/test/widget_test.dart:318 [UTF8-LF sha256=6a210785ac66f598f4772a28afa9bfb6c69379c344d5c4e08b24d1628af215c9; anchor=Peer ACK loss removes table and independent cards]`. - Test-code evidence: `mobile/test/api_test.dart:154 [UTF8-LF sha256=13fe2bd841ff70ffd8b87da264e84fa758885d8413c7f96493211abe17984b72; anchor=Changed endpoint discards an in-flight status]`. -- Test-code evidence: `tests/device_context_test.cpp:4 [UTF8-LF sha256=641d5b37785aed48b4e3b141735622e9c8463a8127e929b17ce4c8a2844a5265; anchor=int main(]`. +- Test-code evidence: `tests/device_context_test.cpp:4 [UTF8-LF sha256=60d085c8a02ea52a3aa88875f24e22387ef9dd41415f9847127207fad0e535a3; anchor=int main(]`. - Test-code evidence: `tests/led_backend_integration_test.mjs:23 [UTF8-LF sha256=011bfd16a540618bea7a79a4d0977f99832738075bf896688d00df3717639689; anchor=Both baked roles must advertise the shared LED frame service]`. - Remaining: Native execution and generic PCController profile/discovery adapter remain #10/#6 work; source/C++/Web proof is not native or physical acceptance. @@ -2725,7 +2984,7 @@ Verdict: **equivalent-behavior**. Revision: `b0111ae0c80dc34149ea1044de7dd548892 Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `7a3e955ddc771cf27f9e545c11ea992a1342a7ed5eaeba9d9a5b95d00dacd2bc`. -- Target: `firmware/esp/src/WebUi.h:2212 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function workspaceSettingsRecord]`. +- Target: `firmware/esp/src/WebUi.h:2472 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function workspaceSettingsRecord]`. - Retained: Completely read actual allowlisted normalized local-preferences and unavailable-storage behavior; adapt bounds/type validation and storage-disabled retention to passive board/application Web workspaces. - Omitted/replaced code: `Full donor quick-header action/catalog UI`. Disposition: **pending**. Reason: This reuses normalization/persistence, not every quick-header control; individual capability-gated donor controls need separate mapping. - Test-code evidence: `tests/web_workspace_test.mjs:115 [UTF8-LF sha256=e35df2215cf2802e9e58896204d4c44dbb12225411a7c511808f47af9679d7cf; anchor=Disabled browser storage still retains this session workspace]`. @@ -2749,7 +3008,7 @@ Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. O Verdict: **parity-gap**. Revision: `b0111ae0c80dc34149ea1044de7dd5488920301b`. Original-byte SHA-256: `f600267bf09d925eaf9e5cc3d752574a0a3fbc46350782c614440300c08799e4`. - Target: `assets/web/frame-stream.js:9 [UTF8-LF sha256=3d0ab90616b173570e5618be76aded339754caa92961868a1d9b43a02e3c6d90; anchor=function createFrameStream]`. -- Target: `firmware/esp/src/WebUi.h:3367 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function readStreamPolicy]`. +- Target: `firmware/esp/src/WebUi.h:3785 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function readStreamPolicy]`. - Retained: Full source read through EOF. Actual injected clock, generation admission, one scheduled callback, cancellation and disposal structure adapted to the mechanically embedded latest-frame Web stream. - Retained: Stale callbacks check generation before clearing the timer handle, strengthening the donor against disarming a replacement owner. Observed board/boot and exact socket ownership replace build/name-only identity for this bounded slice. - Omitted/replaced code: `pendingStatus history queue and applyStatusFrameBatch ordered metrics/error reducer`. Disposition: **pending**. Reason: All status arrivals are useful for telemetry/history, but stale LED actuations must not be replayed. This latest-only send scheduler does not implement the donor telemetry reducer; retain it as separate useful work. @@ -2818,9 +3077,9 @@ Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cd Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `bba9a295ee7b59e318f76e0b6f6a80f0aec631e249f7d8c798f6c92c0bb7fd8c`. -- Target: `.github/workflows/build.yml:2 [UTF8-LF sha256=83a9772a380f29a70886fad3b0462c3f17aa0c46f565d9feec32c57aba37c498; anchor=name: "🚀 Build · Test · Release"]`. +- Target: `.github/workflows/build.yml:2 [UTF8-LF sha256=2011a5c6db4cce2042b223c1724d4fc6481d140f7aaf68527bc0f8ccf79c363e; anchor=name: "🚀 Build · Test · Release"]`. - Target: `tools/build-desktop.ps1:47 [UTF8-LF sha256=8ebd02ebcd5556bd70f97216faed6ea3c03ec8e92950514118e1b9901132e037; anchor=--test-threads=1]`. -- Target: `tools/ci-checks.mjs:259 [UTF8-LF sha256=bf1673bcfadf4ad4d78b2b011079e3c30b7bf072649a037b9ade59bf0a9de301; anchor=export function runChecks]`. +- Target: `tools/ci-checks.mjs:274 [UTF8-LF sha256=185205c24d8ebf5fbd4b26499841843093544c3f1f626310d901f0f41c229c07; anchor=export function runChecks]`. - Target: `tools/prepare-release.mjs:18 [UTF8-LF sha256=820cefc9698d46f95cf39b4cd667494a05cd03d119a1c54f9892b2a35d8b824b; anchor=export function firmwareManifest]`. - Retained: Actual donor branch/PR/tag/dispatch/merge-group triggers, concurrency policy, pinned actions without checkout credentials, health-before-build jobs, timeouts, Rust cache reuse, locked tests/builds, packaging, curated notes, manifest/checksums/provenance and aggregate results adapted to current Windows/ESP/AVR/Android targets. - Retained: Release content is gated on all supported-target builds, created as an immutable draft prerelease, with the matched S3/AVR buffered pair and raw HTTPS update manifests. Explicit legacy packaging is preserved but the overflowing ESP8266 image is not promised as a release output. Untrusted PRs do not execute publishing code with a write token. @@ -2892,7 +3151,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Target: `desktop/src/platform/windows_tray.rs:17 [UTF8-LF sha256=94bd79acba0dd4d572222cb064e451a274ee38579a392c95c56d997779cc35bf; anchor=struct TooltipSync]`. - Target: `desktop/src/platform/windows_notifications.rs:158 [UTF8-LF sha256=71f2254825ba2b16d83696ce0e0cf74ec7c95eedda5f60e1c3d167826693ec1a; anchor=fn tooltip_data(]`. -- Target: `desktop/src/main.rs:5168 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=event_log.failure("Windows tray tooltip", &error)]`. +- Target: `desktop/src/main.rs:5195 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=event_log.failure("Windows tray tooltip", &error)]`. - Retained: Full donor plan read through EOF as source requirements, not trusted commands. Generic existing tray lifecycle/menu/disposal retained; actual windows.rs NIM_MODIFY/TIP sections read and adapted to the owned GUID rather than duplicate registration. Tooltip Shell acceptance is separated from object presence; failed writes retain pending latest state with bounded retry/incident recovery and observed restart epoch invalidation. - Omitted/replaced code: `Playback percent/error/paused inputs, media thumbnail toolbar Previous/Play/Next/Mute and video-only iconic clipping`. Disposition: **not-applicable**. Reason: Pakar is explicitly mpv-free and owns controller operations, not playback/chapter/video. No synthetic media percentages or nonexistent actions are imported; actual pending-operation activity remains. - Omitted/replaced code: `Full root taskbar initialization/retry, thumbnail toolbar lifecycle, visible tray/menu/notification clicks and all Windows shell suite acceptance`. Disposition: **pending**. Reason: This fixes only GUID-bound tooltip synchronization on an already owned tray object. Mock clocks, NOTIFYICONDATA assertions and observed restart messages cannot certify complete root shell readiness or user-visible action delivery. @@ -2920,8 +3179,8 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `57fbd904e18d8f0036dd67e2c866d357ab282075689a1cbf6b38a64270875492`. - Target: `desktop/src/platform/windows_tray.rs:14 [UTF8-LF sha256=94bd79acba0dd4d572222cb064e451a274ee38579a392c95c56d997779cc35bf; anchor=Shell acceptance, not object presence]`. -- Target: `desktop/src/ui/shell.rs:1314 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=value["tray_shell"]]`. -- Target: `tools/replace-desktop.ps1:42 [UTF8-LF sha256=13e5893983bf422449d0c35b94305a39bb662a1663511a3706125bb8e9fc484d; anchor=function OwnerStatus]`. +- Target: `desktop/src/ui/shell.rs:1332 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=value["tray_shell"]]`. +- Target: `tools/replace-desktop.ps1:42 [UTF8-LF sha256=95a7f84383869622426e97d7c21d579ce93fd5c55d2bd4607697b6b352bbe525; anchor=function OwnerStatus]`. - Target: `tools/observe-desktop.ps1:28 [UTF8-LF sha256=c2adb041290e63d2190c6e807c095b9a79bf3c201d46f5ffc085636c1d4daf5e; anchor=function OwnerStatus]`. - Retained: Full donor document read through EOF. Apply its concrete distinction between working IPC/service and actual shell/GUI readiness: expose tray Shell acceptance separately, retain failures and recover only after accepted synchronization, verify open drafts before graceful replacement and keep actual runtime proof separate. - Retained: Actual already-owned ASUS replacement/capture helpers were reread and their PID/path/hash/session/no-draft/graceful-exit/atomic-rollback checks adapted into reusable explicit-parameter replacement and600s USB/Wi-Fi observation tools. Both preserve settings and reject unknown owners; observation captures actual message-hub and Windows Shell counters, buffers samples in RAM and writes bounded progress only on changes. This imports recovery boundaries, not all donor shell features. @@ -2935,7 +3194,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `4e78a6c486bfc7d78adc6d34552bddfd67c89997edb8d2bae0b46e70c705dcb6`. -- Target: `README.md:7 [UTF8-LF sha256=c97b6901dc78cbac665d27760bdb129d0dfe1705e51584bba5d6fa0cd5cf7e04; anchor=Connected boards. One contextual control workspace.]`. +- Target: `README.md:7 [UTF8-LF sha256=6ef2bde812ca8aaf889f5ad1c8313ae6e4e5ac079b93424ef4be6e2cdd007da5; anchor=Connected boards. One contextual control workspace.]`. - Target: `tools/generate-readme-artwork.mjs:1 [UTF8-LF sha256=2162d1ad3760618260eb9d659dfebb6651ad1e5f5b4eb2c37c12cde1ef7ff8d2; anchor=README uses the same bytes]`. - Retained: Pealayer's actual centered logo/title/tagline, badge row, visual preview, section navigation, architecture, build, docs, contribution and license structure adapted to Pakar. - Omitted/replaced code: `Media engine, NLE cue/timeline walkthroughs, playback APIs/shortcuts and libmpv installation sections`. Disposition: **not-applicable**. Reason: These describe Pealayer media business/runtime rather than Pakar board control. README links actual controller APIs, wiring and release/acceptance instead; no mpv is linked. @@ -2979,7 +3238,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `New Get-PealayerCargoTargetDirectory rejection of all source-checkout cache paths`. Disposition: **pending**. Reason: Pakar's existing sole desktop/target is inside the checkout. Applying this rejection now would strand the warm cache or force a duplicate cold build. An owned same-volume relocation and wrapper/config migration are required first; the oversized-cache guard remains active and blocked cleanup is not bypassed. - Omitted/replaced code: `Copy-PealayerFileLowWrite delete-destination-first copy and PreferHardLink`. Disposition: **replaced**. Reason: Pakar retains its checksum-verified pending-copy then rename. The prior candidate is preserved until verification; a mutable executable is not hardlinked to compiler output. mpv immutable-runtime aliases are explicitly excluded, not a reason to omit generic content comparison. - Test-code evidence: `tests/reuse_preflight_test.ps1:83 [UTF8-LF sha256=842c7d0149c2717f7378116063712f4b719941152d89bd43cdc31e60b148437d; anchor=Actual donor relative cache resolution]`. -- Test-code evidence: `tests/storage_hygiene_test.ps1:15 [UTF8-LF sha256=7e6d4223563e3e5f2b641928c87cda91fa06312815434d63d46b76602cd49dca; anchor=Actual imported equal-content helper]`. +- Test-code evidence: `tests/storage_hygiene_test.ps1:19 [UTF8-LF sha256=e465eb90d9957a10586110273f35a6c7ce8107c679f3dfbd7ab10d68aa798899; anchor=Actual imported equal-content helper]`. - Remaining: Adapt generic saved host provenance/config ownership checks without mpv, and keep one owned cache if worktrees are later authorized. ### Pealayer: `scripts/package-probe-windows.ps1` @@ -2987,7 +3246,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **equivalent-file**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `f4516884990ad76c020fbc6534d902c4949abae0b48da8ea40c008afa44dfd4f`. - Target: `tools/package-probe-windows.ps1:27 [UTF8-LF sha256=71604ab464776891dbc95db7123218c22b3ede09ce2835fe4659d75c6d0b9d5e; anchor=function Invoke-PackageProbe]`. -- Target: `tools/replace-desktop.ps1:68 [UTF8-LF sha256=13e5893983bf422449d0c35b94305a39bb662a1663511a3706125bb8e9fc484d; anchor=$reply=Invoke-PackageProbe]`. +- Target: `tools/replace-desktop.ps1:71 [UTF8-LF sha256=95a7f84383869622426e97d7c21d579ce93fd5c55d2bd4607697b6b352bbe525; anchor=$reply=Invoke-PackageProbe]`. - Retained: Byte-identical actual donor process-handle probe: no-shell hidden process, async redirected stdout, 30-second wait, explicit exit code, output return and Dispose. Optional staging probe calls this helper. - Retained: Desktop replacement now also calls the retained process probe for before/after owner status and its one graceful quit. Actual ASUS inspection showed direct GUI-subsystem invocation could report false IPC failure; the retained probe read the same ready owner successfully. Live replacement preserved settings/rollback and recovered USB/80-cell mapping on the same ESP boot. - No production-code omission claimed for this reviewed file. @@ -3027,15 +3286,15 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `0d8fdeb3e15fb52b917784d4cb3875c68bc12c6c6ed78cd16f2ff8ee3561d89c`. -- Target: `tools/storage-hygiene.ps1:11 [UTF8-LF sha256=0d1af57a83775e2670a5ac222973970dfc3bfc2c2e1bde1a1753523b444bdb5c; anchor=function Get-DirectorySummary]`. +- Target: `tools/storage-hygiene.ps1:11 [UTF8-LF sha256=3d1091c2761add106a5104f671bf12793c9621803f38bd898b193f597eabb329; anchor=function Get-DirectorySummary]`. - Target: `tools/build-mobile.ps1:85 [UTF8-LF sha256=3d91a7320c7c719cb870fa461fdf2fba799cf1fcebc30805937c32b966e40362; anchor=$taskCache=Get-DirectorySummary]`. - Retained: Actual Get-DirectorySummary imported into tools/storage-hygiene.ps1 with Pakar canonical cache, board cache, mobile build and stable staging paths. Read-only by default and warns at the existing 8 GiB budget; logical sizes explicitly distinguished from disk allocation or wear. - Retained: build-mobile.ps1 now calls the actual imported summary for its sole mobile/build cache and verifies free space before compilation. Existing SDK path comes from mobile README; no auto-install, dependency upgrade, cache relocation or clean. - Omitted/replaced code: `PruneRepositoryTarget exact-child/git-ignore/allowed-child/running-process checks and recursive deletion`. Disposition: **replaced**. Reason: New explicit user authorization permits obsolete debug-only deletion. Adapt the actual donor exact-child, ignore, allowed-content, reparse and writer checks to the owned desktop/target/debug; add tracked-file refusal and Plan. Never remove the target root, release cache, settings or rollback. The verified 10.408 GiB debug tree was pruned; stronger guards keep the read-only default intact. - Omitted/replaced code: `Pealayer target/shared-cache/staging paths and 48 GiB warning`. Disposition: **replaced**. Reason: Pakar uses its actual existing sole cache, Arduino and Flutter products and stable outputs staging, with the established 8 GiB warning. Importing another project's directories would misstate ownership. - Omitted/replaced code: `Silently ignored recursive read errors and empty Measure-Object Sum`. Disposition: **replaced**. Reason: Unmeasured paths must fail visibly rather than understate storage. Empty directories use zero bytes instead of a missing StrictMode Sum property. -- Test-code evidence: `tests/storage_hygiene_test.ps1:10 [UTF8-LF sha256=7e6d4223563e3e5f2b641928c87cda91fa06312815434d63d46b76602cd49dca; anchor=Actual imported recursive summary]`. -- Test-code evidence: `tests/storage_hygiene_test.ps1:28 [UTF8-LF sha256=7e6d4223563e3e5f2b641928c87cda91fa06312815434d63d46b76602cd49dca; anchor=Default inventory stays read-only]`. +- Test-code evidence: `tests/storage_hygiene_test.ps1:10 [UTF8-LF sha256=e465eb90d9957a10586110273f35a6c7ce8107c679f3dfbd7ab10d68aa798899; anchor=Actual imported recursive summary]`. +- Test-code evidence: `tests/storage_hygiene_test.ps1:32 [UTF8-LF sha256=e465eb90d9957a10586110273f35a6c7ce8107c679f3dfbd7ab10d68aa798899; anchor=Default inventory stays read-only]`. - Test-code evidence: `tests/reuse_preflight_test.ps1:97 [UTF8-LF sha256=842c7d0149c2717f7378116063712f4b719941152d89bd43cdc31e60b148437d; anchor=Mobile plan retains reviewed gates]`. - Remaining: Debug-only cleanup is now authorized and implemented. Canonical nonduplicating RAM-disk/cache relocation, the newer donor Cargo-metadata/cache-tag proof for shared-cache migration, full fixture safety matrix and exact allocated-vs-logical accounting remain pending; no RAM disk has been allocated. Donor storage/CI files were reread while dirty concurrent peer work, not misrepresented as a new clean donor commit. @@ -3058,9 +3317,9 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `1f173bef8acc2904eac5b0a91407aa4f7629ce65c88ea3a8ac78f8c13321d203`. - Target: `desktop/src/application_shortcuts.rs:13 [UTF8-LF sha256=a970f63b589923be7a11260ad192a921e79b0fd33b7faeb631c6ab99d94750c1; anchor=pub struct ApplicationShortcuts]`. -- Target: `desktop/src/config.rs:186 [UTF8-LF sha256=da1ad74dc53c474cb4b478cc0557c65e5daf2bc14fb9e97f88eaf5be76c0a862; anchor=pub application_shortcuts:]`. +- Target: `desktop/src/config.rs:186 [UTF8-LF sha256=b84801a85e31a44b2691bd5f6ccefea0106d799952b0fb61e2d6162ee11b9414; anchor=pub application_shortcuts:]`. - Target: `desktop/src/ui/preferences.rs:466 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=Keyboard shortcuts]`. -- Target: `desktop/src/ui/shell.rs:1362 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=crate::application_shortcuts::take_action]`. +- Target: `desktop/src/ui/shell.rs:1380 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=crate::application_shortcuts::take_action]`. - Retained: Imported the donor's reusable persisted shortcut model for six controller actions: fullscreen, refresh, Preferences, device settings, connect and cell mapping. Parsing uses physical key codes, exact modifiers, initial key presses only, validation rejects duplicates/invalid values, and text editing protects bare printable keys. - Retained: Menu accelerator labels and event dispatch read the same saved configuration. Empty bindings disable an action; one matched event is consumed once. Controller actions replace the donor's media-only operations without inventing playback behavior. - Omitted/replaced code: `take_frame_step_shortcut and frame-step tests`. Disposition: **not-applicable**. Reason: These consume Ctrl+Left/Right for media frame stepping and timeline transport. Those business domains were explicitly removed; Pakar must not hijack ordinary text word navigation for nonexistent video frames. @@ -3077,7 +3336,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Target: `desktop/src/brand_image.rs:25 [UTF8-LF sha256=ab71607dcde16d7af14b0a292982f902186dacf6b5b78b3f4c983e37464428e9; anchor=pub fn native_icon_bytes]`. - Target: `docs/BRANDING-PARITY.md:60 [UTF8-LF sha256=0bfa8122dc9d6034551304f2fed472ddebcff170e14a23f63011177aab75d2d3; anchor=## Explicit unfinished useful code and reasons]`. - Target: `desktop/src/branding.rs:9 [UTF8-LF sha256=13a85d887a09acb79aed6acb7b8c0e38f9ea1ba2e35e9306c8bf7558211bc211; anchor=pub fn sync_peer]`. -- Target: `mobile/lib/main.dart:1654 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=peer.application?.icon ?? bundledApplicationArtwork]`. +- Target: `mobile/lib/main.dart:1670 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=peer.application?.icon ?? bundledApplicationArtwork]`. - Target: `mobile/lib/application_artwork.dart:2 [UTF8-LF sha256=a1ee8fc15b0aa9111a5c416fcf74b3d05093b22e47b76d7744837f175f7b5ef4; anchor=const applicationArtworkBase64]`. - Retained: Actual icon_data_from_bytes imported unchanged; one embedded PNG supplies native/Web artwork without disk caches. - Retained: Actual native_icon_bytes seven-frame ICO encoding/master preservation/Lanczos3 resampling and shell derivative test adapted to shared supplied bytes and PNG-only decoder. @@ -3109,7 +3368,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `92b065a4d8ff01ae3b4a81fa11fb6e5b671f85bb1f667be6af389e692fef9f71`. - Target: `desktop/src/cli_console.rs:26 [UTF8-LF sha256=bdf70294c5d4a40007baaf637a8ee26a8576f6dfd03604274fdd9a7d3de2a4ac; anchor=fn write_output(]`. -- Target: `desktop/src/main.rs:5779 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=cli_println!("{reply}")]`. +- Target: `desktop/src/main.rs:5806 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=cli_println!("{reply}")]`. - Target: `desktop/src/terminal_ui.rs:994 [UTF8-LF sha256=762c7ee8137a21fdbd83a40bb776238a6f75b943da35d01073bbff6b2dd64379; anchor=cli_eprintln!(]`. - Retained: Lamp-background checkpoint scoped rereview of actual cli_output/write_cli_line and complete Pakar cli_console.rs. New explicit CLI option retains inherited pipe handles and fallible non-panicking output; GUI launches never attach or allocate a terminal. Inspected consumer hashes are rebound without claiming unrelated diagnostic/reporting parity. - Retained: Read complete152-line6485-byte donor through EOF. Actual cli_println/cli_eprintln, cli_output, write_cli_line and disconnected_cli_output_returns_an_error_without_panicking are adapted into cli_console. All primary native stdout/stderr call sites use fallible writes, including IPC, help/version, shell errors, management and TUI startup. Partial print support retains the existing table text interface without adding newline artifacts. @@ -3125,7 +3384,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `452eb004c52bdcff6936de5602cfdddf5c925d58db9e1e48220fc9a21075b696`. -- Target: `desktop/src/main.rs:2 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=#![cfg_attr(windows, windows_subsystem = "windows")]]`. +- Target: `desktop/src/main.rs:2 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=#![cfg_attr(windows, windows_subsystem = "windows")]]`. - Target: `desktop/src/cli_console.rs:69 [UTF8-LF sha256=bdf70294c5d4a40007baaf637a8ee26a8576f6dfd03604274fdd9a7d3de2a4ac; anchor=pub fn prepare(]`. - Target: `desktop/src/application_identity.rs:3 [UTF8-LF sha256=c37a32df5c00a1cc559c58d226b87d6e9756e7d837b56488646bd0ecc54f26bb; anchor=pub const WINDOWS_APP_ID]`. - Target: `desktop/src/platform/windows_jump_list.rs:13 [UTF8-LF sha256=847796332bd1ba7dd3c51a38a1699b0881ae7efe29249d8129de00d2a481d841; anchor=wide_null(crate::application_identity::WINDOWS_APP_ID)]`. @@ -3143,13 +3402,13 @@ Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1c Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `44ba995614f2acc3230f6adc13c00500e6844db81aa36d5e60e7eeb9e90d6f9f`. - Target: `desktop/src/messaging.rs:83 [UTF8-LF sha256=57e357298d9dd8ae515a21bd13a4959ef77e684b345aed02da9dab818f5a204c; anchor=pub fn publish(]`. -- Target: `desktop/src/ui/shell.rs:1291 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=value["messages"]]`. -- Target: `desktop/src/platform/notifications.rs:177 [UTF8-LF sha256=80ad054412b142759a5ec8dfffb8f2dfa8551ad34e4e41fa91f14c47687d5d43; anchor=pub struct ConnectionNotice]`. +- Target: `desktop/src/ui/shell.rs:1309 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=value["messages"]]`. +- Target: `desktop/src/platform/notifications.rs:187 [UTF8-LF sha256=7bc00792827606683cac5665eaf5d72f8e04f9a3667dc38e54267fa24451f574; anchor=pub struct ConnectionNotice]`. - Retained: The current 226-line donor was completely reread. Pakar retains request validation, bounded queue, expiry/upsert/dismiss/revision/source identity and the original tests, and now imports Toast.details, publish_with_details, public publish_detailed and the 8192-character pre-mutation validation. - Retained: The shared bounded/upsert message hub has truthful native owner-IPC snapshot exposure for actual toast monitoring. Host connection alerts require2s stable state to avoid transient loss/recovery pairs; command freshness still changes immediately. Distinct diagnostic causes remain in the incident ledger, while detailed failures preserve concise notification copy. - No production-code omission claimed for this reviewed file. - Test-code evidence: `desktop/src/messaging.rs:199 [UTF8-LF sha256=57e357298d9dd8ae515a21bd13a4959ef77e684b345aed02da9dab818f5a204c; anchor=fn concise_notification_retains_bounded_original_diagnostics()]`. -- Test-code evidence: `desktop/src/platform/notifications.rs:406 [UTF8-LF sha256=80ad054412b142759a5ec8dfffb8f2dfa8551ad34e4e41fa91f14c47687d5d43; anchor=fn brief_status_and_avr_flaps_do_not_generate_disconnect_recovery_pairs]`. +- Test-code evidence: `desktop/src/platform/notifications.rs:430 [UTF8-LF sha256=7bc00792827606683cac5665eaf5d72f8e04f9a3667dc38e54267fa24451f574; anchor=fn brief_status_and_avr_flaps_do_not_generate_disconnect_recovery_pairs]`. - Scoped advantage: Preserves the donor hub while integrating detailed failures with Pakar's source-specific incident deduplication, concise UI copy, privacy-safe Windows notices and stable connection-loss notification policy. - Remaining: Current donor native messaging behavior is imported; Web/TUI/IPC notification-surface parity and installed visual acceptance remain separate cross-surface work. @@ -3164,6 +3423,19 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Test-code evidence: `desktop/src/platform/lan_discovery.rs:791 [UTF8-LF sha256=870da3158a7edbf09572ff28eb7878ca4fc4d91d72b3c321a342abff6ee8bfe1; anchor=fn bridge_loopback_registration_starts_no_discovery_transports]`. - Remaining: Issue #2: discover_bind_targets rich interface chooser, address_detail/interface_presentation and parse_bind_addresses multiple binds. Core native USB/network command sharing has source/socket fixtures; actual ASUS/LAN/Android/physical acceptance and full opaque firmware/binary streaming forwarding remain open. +### Pealayer: `src/platform/window_icons.rs` + +Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `63bbaa244728a2fb4bd53ad5c35f7ff4b95790e70358d47c34ae704ea79d4a4e`. + +- Target: `desktop/src/platform/windows_window_icons.rs:138 [UTF8-LF sha256=bc430351f14d15d6559e6f1dd8a593329c1e05cf7661278856d959b9ab539f6b; anchor=pub fn apply(]`. +- Target: `desktop/src/ui/shell.rs:1167 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=desktop.set_icon(&icon)]`. +- Retained: Read the complete current donor at 5a6961ce. Adapt its actual owned HICON cache, alpha icons sized for window DPI, both ICON_SMALL/ICON_BIG slots, subclass protection against framework overwrites, WM_DPICHANGED refresh, and WM_NCDESTROY disposal. The selected peer artwork now reaches both taskbar and caption through the existing synchronize_context owner. +- Retained: Retain both actual hidden-window lifecycle tests: real Win32 icon slots, borrowed-handle survival, replacement/disposal, DPI sizes and true-color bitmaps. Inline the complete taskbar_preview.rs::bitmap dependency function (361..393), without importing media preview behavior. Diagnostic logging uses stderr because Pakar has no log dependency. +- No production-code omission claimed for this reviewed file. +- Test-code evidence: `desktop/src/platform/windows_window_icons.rs:209 [UTF8-LF sha256=bc430351f14d15d6559e6f1dd8a593329c1e05cf7661278856d959b9ab539f6b; anchor=fn hidden_window_keeps_both_icon_slots_and_releases_only_owned_handles()]`. +- Test-code evidence: `desktop/src/platform/windows_window_icons.rs:310 [UTF8-LF sha256=bc430351f14d15d6559e6f1dd8a593329c1e05cf7661278856d959b9ab539f6b; anchor=fn application_icons_use_true_color_alpha_bitmaps_at_each_native_size()]`. +- Remaining: Both real Win32 hidden-window icon tests and shared branding checks passed. Confirm actual taskbar appearance after deploying the updated Rust executable; pinned launcher artwork and stable application grouping are unchanged. + ### Pealayer: `src/platform/windows_quick_actions.rs` Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `35f6de708a0e402f6958a4d8feeead5037b42174286050ddc98c494d4173e4cb`. @@ -3199,15 +3471,15 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `ab1c99e674e902e37626963e2dd5cf7d993965334411d5e33dafd1ea2bf79e51`. -- Target: `desktop/src/capabilities.rs:7 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=pub struct Manifest]`. -- Target: `desktop/src/ui/shell.rs:1052 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn synchronize_context(]`. -- Target: `desktop/src/ui/device_settings.rs:23 [UTF8-LF sha256=6988aa130e2f23e44b0b1fbb3f77e7ae4f221babcc4c3ed21ce0726e06c01ed3; anchor=pub(crate) fn available(]`. +- Target: `desktop/src/capabilities.rs:7 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=pub struct Manifest]`. +- Target: `desktop/src/ui/shell.rs:1062 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn synchronize_context(]`. +- Target: `desktop/src/ui/device_settings.rs:23 [UTF8-LF sha256=c087cbbde3bfc194b6002b10a3b63bb16ac4e253ef33d1a6f951ab2e566a42f6; anchor=pub(crate) fn available(]`. - Target: `docs/DEVICE-CONTEXT.md:84 [UTF8-LF sha256=3d7c4a2b05e71ed13fc0f048d852c84d31b52cb1cf63e76180591a6f17a22fe8; anchor=## Exact reuse and remaining useful code]`. - Target: `desktop/src/board_silent.rs:128 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=pub struct Transaction]`. - Target: `desktop/src/ui/preferences.rs:262 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn board_controls(]`. -- Target: `mobile/lib/api.dart:1882 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future boardSound(]`. +- Target: `mobile/lib/api.dart:2109 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future boardSound(]`. - Target: `mobile/lib/board_sound_preferences.dart:10 [UTF8-LF sha256=888e518c1a85ff594b6c4c778f2eb770a4cbcad3a1d53d3defea19b5e226dc5d; anchor=class BoardSoundPreferences]`. -- Target: `firmware/esp/src/WebUi.h:3124 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=async function boardSoundTransaction(]`. +- Target: `firmware/esp/src/WebUi.h:3542 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=async function boardSoundTransaction(]`. - Retained: Reread all 1280 lines through EOF. Prior authoritative overview/capability/front-panel adaptation remains; shared dropdown dependency is now imported, but full typed board settings and ACK consumers remain pending. - Retained: Numeric display controls are independent of Text; peer title follows acknowledged name and lost capabilities cancel Table work. - Retained: Reread complete donor source through EOF. Adapt MCU-owned persistent Silent bit independent of host/Windows notification audio, capability-acknowledged controls, explicit unknown/pending/verified states and full-record preservation. Native Preferences Board page uses existing PCController GET/SET/GET with real AVR ACK and persistent readback over the selected fenced HTTP or USB owner only; Web and Flutter Preferences use the same living contract. No unsupported buzzer is invented, no automatic uncertain mutation replay, and unchanged persisted flags skip EEPROM writes. @@ -3218,10 +3490,10 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `front_panel_key_button and acknowledged menu/key/layout readback`. Disposition: **pending**. Reason: Nine-key state exists, but full remotely navigable front-panel/menu/key commands need per-board descriptors and donor ACK tests. Incompatible labels do not justify dropping reusable command helpers. - Omitted/replaced code: `seven_segment_preview, section/row drawing`. Disposition: **replaced**. Reason: Existing Pakar seven_segment/dialog primitives own preview and geometry. A duplicate renderer would fork styling; fresh peer gates wrap the current owners. Native paint acceptance remains pending. - Omitted/replaced code: `DropdownUiExt / dropdown::ComboBox board enum selector integration`. Disposition: **pending**. Reason: The tracked shared dropdown module is now fully read and imported, with native consumers and donor tests. This does not implement every generic consumer contract in this file: render-rate policy, workspace/language/direction actions and complete board EEPROM enum ownership remain independently pending. Native execution and visual acceptance are reported separately. -- Test-code evidence: `tests/web_context_test.mjs:168 [UTF8-LF sha256=e364947a8d6f93c1fc56cfbfa050f722a033ba63f24d30190238b6bfb0d95f92; anchor=Native wiring is source-checked only]`. -- Test-code evidence: `desktop/src/capabilities.rs:603 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=fn no_profile_or_individual_ack_means_no_controls]`. -- Test-code evidence: `tests/tone_player_test.cpp:45 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=const auto beforeMute]`. -- Test-code evidence: `tests/pc_contract_test.cpp:115 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentRequest[22]]`. +- Test-code evidence: `tests/web_context_test.mjs:168 [UTF8-LF sha256=a76a645ffaca445ca0c4ec835a3e577f781b7f05a0f88d14ca326f303b2cdf02; anchor=Native wiring is source-checked only]`. +- Test-code evidence: `desktop/src/capabilities.rs:605 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=fn no_profile_or_individual_ack_means_no_controls]`. +- Test-code evidence: `tests/tone_player_test.cpp:46 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=const auto beforeMute]`. +- Test-code evidence: `tests/pc_contract_test.cpp:118 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentRequest[22]]`. - Test-code evidence: `desktop/src/board_silent.rs:446 [UTF8-LF sha256=e9c8ad97498076d6d7d84531b8558e18c2251aa1e4e0dce2d8b21918eb4c1e54; anchor=fn board_silent_preserves_other_settings]`. - Test-code evidence: `mobile/test/board_sound_test.dart:229 [UTF8-LF sha256=e37acd59e38b8dc8696eb190f3018444352128cf0eb13ad59d125cf007cd5a45; anchor=Silent API uses actual AVR ACK/readback]`. - Test-code evidence: `tests/board_silent_test.mjs:9 [UTF8-LF sha256=95f4c3048054666df7673c618f09f57d7e2c37d34f42276b0fcd021037772825; anchor=for (const mode of []`. @@ -3236,7 +3508,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Target: `desktop/src/ui/color_picker.rs:25 [UTF8-LF sha256=3a42e1d960fab77e1496899d7c708882f5db1ece1e92c01bd3192bc3e9eef0d5; anchor=pub fn remember_color(]`. - Target: `desktop/src/ui/color_picker.rs:243 [UTF8-LF sha256=3a42e1d960fab77e1496899d7c708882f5db1ece1e92c01bd3192bc3e9eef0d5; anchor=pub fn accent_picker(]`. - Target: `desktop/src/ui/preferences.rs:445 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=super::color_picker::accent_picker]`. -- Target: `desktop/src/ui/i18n.rs:198 [UTF8-LF sha256=21c1e90d6eb7057d4774f21a2b8524fb20411bceed3cb050db795477974d3de1; anchor="Recent colors"]`. +- Target: `desktop/src/ui/i18n.rs:198 [UTF8-LF sha256=0f06c9f2f84e5710fe5bbea7a83221c0c527e61ba52ab0b63ee032974ced6739; anchor="Recent colors"]`. - Retained: Lamp-background checkpoint scoped rereview of configure_language/palette_tr/recent_row, current accent_picker consumer and Recent colors translation. Board-palette face preference stays separate from accent picker; recent/session colors, validated draft and localized palette labels are unchanged. Current preference/i18n hashes rebound without claiming pending color-picker parity. - Retained: Reread all 550 current donor lines through EOF. Actual session-only12-color MRU deduplication, recent/shared swatches, integrated HEX-field swatch, full-width Custom choice, independently anchored palette/validated temporary HEX draft and prior donor tests remain adapted. The newer integrated Custom expansion/density behavior is pending; current production accent consumer is not a complete Preferences contract. - Retained: Scoped hover/grid checkpoint rereview: donor configure_language, palette_tr and recent_row (lines 5-77) still route Recent colors through translation; current Pakar Recent colors/Shared colors/Choose color labels remain intact. The current target changes add first-wins grid labels, not color-picker behavior. Refreshed this target hash after reading those exact functions/labels; the earlier full-file review and all pending omissions remain historical and unchanged. No new palette runtime acceptance is claimed. @@ -3253,7 +3525,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `690a964825638da4272550f389a948ad62b5947bad03df8aaee60de49c1325ed`. - Target: `desktop/src/ui/control_density.rs:7 [UTF8-LF sha256=d5e8b6ae76c101f6473297f4305061ca8b5d83a7e1f4137850383a66b9b1ff6c; anchor=pub fn configure(style: &mut egui::Style, density: UiControlDensity)]`. -- Target: `desktop/src/config.rs:38 [UTF8-LF sha256=da1ad74dc53c474cb4b478cc0557c65e5daf2bc14fb9e97f88eaf5be76c0a862; anchor=pub enum UiControlDensity]`. +- Target: `desktop/src/config.rs:38 [UTF8-LF sha256=b84801a85e31a44b2691bd5f6ccefea0106d799952b0fb61e2d6162ee11b9414; anchor=pub enum UiControlDensity]`. - Target: `desktop/src/ui/preferences.rs:447 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=from_id_salt("control-density")]`. - Target: `desktop/src/ui/dropdown.rs:36 [UTF8-LF sha256=92376984276d5bc4714cd59270ba839fa3b00b66d68ba9e8a03e7024870af4d8; anchor=crate::ui::control_density::paint_chevron]`. - Target: `desktop/src/ui/color_picker.rs:162 [UTF8-LF sha256=3a42e1d960fab77e1496899d7c708882f5db1ece1e92c01bd3192bc3e9eef0d5; anchor=crate::ui::control_density::height(ui)]`. @@ -3263,29 +3535,29 @@ Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1c - Omitted/replaced code: `Donor singleline_text_edit(ui, text) without RTL alignment`. Disposition: **replaced**. Reason: Pakar preserves the donor's Button font, dynamic minimum height and zero vertical margin while retaining its required Persian/LTR input alignment. The focused density matrix exercises that production helper. - Omitted/replaced code: `Donor color_dropdown row in the density fixture`. Disposition: **replaced**. Reason: Pakar's current color consumer separates the dropdown trigger and custom palette popup; the test covers its real shared dropdown trigger, custom row and HEX field instead of adding a dormant donor-only API. - Test-code evidence: `desktop/src/ui/control_density.rs:88 [UTF8-LF sha256=d5e8b6ae76c101f6473297f4305061ca8b5d83a7e1f4137850383a66b9b1ff6c; anchor=fn inputs_combos_color_triggers_and_menu_rows_share_density_in_both_themes()]`. -- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:145 [UTF8-LF sha256=2930a5e706fa835f704f097c44e664903aeed7f11b63557408ae2a8411d66219; anchor=fn button_hit_slots_and_caption_ink_survive_every_interaction_state()]`. -- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:150 [UTF8-LF sha256=2930a5e706fa835f704f097c44e664903aeed7f11b63557408ae2a8411d66219; anchor=fn semantic_actions_preserve_inherited_zero_stroke_menu_geometry()]`. +- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:146 [UTF8-LF sha256=4985dc82110f0948c5153b5e9b7b472054b0f62d10a8e2dbb6a38a664848a6a1; anchor=fn button_hit_slots_and_caption_ink_survive_every_interaction_state()]`. +- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:151 [UTF8-LF sha256=4985dc82110f0948c5153b5e9b7b472054b0f62d10a8e2dbb6a38a664848a6a1; anchor=fn semantic_actions_preserve_inherited_zero_stroke_menu_geometry()]`. ### Pealayer: `src/ui/controls.rs` Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `f5211d73c38438f9cff2543ac234965a8dabd26f4a532fb8b8ecf51248fc0a56`. -- Target: `desktop/src/ui/cell_table.rs:368 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn find_field(]`. -- Target: `desktop/src/ui/cell_table.rs:394 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn table_helper(]`. -- Target: `desktop/src/ui/cell_table.rs:1108 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn cell_context_menu(]`. -- Target: `desktop/src/ui/shell.rs:2008 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn draw_table(]`. +- Target: `desktop/src/ui/cell_table.rs:368 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn find_field(]`. +- Target: `desktop/src/ui/cell_table.rs:394 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn table_helper(]`. +- Target: `desktop/src/ui/cell_table.rs:1108 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn cell_context_menu(]`. +- Target: `desktop/src/ui/shell.rs:2030 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn draw_table(]`. - Retained: The native-table owner fully read actual C:/Users/David/AppData/Local/Programs/Pealayer/source/Pealayer/src/ui/controls.rs through EOF in two bounded reads, including history/blame. Adapt draw_elapsed_editor's fixed text-editor slot and centered vertical alignment (originating change 9ea94c09), draw_contextual_transport_nudge's exact allocation and centered glyph, volume_slider_widgets' stable value lane, and transport_context_menu's shared action dispatch/gating. Pakar find_field, table_helper and cell_context_menu use these patterns without importing media commands. - Retained: The table's cell/background/keyboard menus share one action path with capability, connection, pending-command and lost-lock checks; visual row/column selection, inverse/copy, diagnostics and view options remain contextual. Normal selection/status help stays muted, actual cautions/errors use semantic treatment, and the helper lane does not resize with its contents. grid_canvas/visual_line and all-80-cell staggered fit are new Pakar spatial algorithms, not claimed donor table code. - Retained: An actual native framebuffer after the first build exposed centered helper text and a 520px Find width cap. The follow-up uses a leading-edge vertically centered helper galley, a single reserved helper lane, full responsive Find width and pixel telemetry in the existing footer. The strengthened helper test checks exact text left edge/vertical center; a new Find-row test rejects a fixed width cap. Initial test/build success did not establish final visual acceptance; final source must be rebuilt and recaptured separately. - Omitted/replaced code: `Playback transport, elapsed timecode, seek and volume commands`. Disposition: **not-applicable**. Reason: The table controls MCU-owned cell state and has no media playback domain. Only reusable geometry, contextual action routing and semantic helper treatment were adapted. - Omitted/replaced code: `Remaining generic control configuration, input ownership and complete donor runtime test matrix`. Disposition: **pending**. Reason: A complete source read does not establish all-controls parity. This pass maps the exact listed functions to table consumers; other useful generic configuration and cross-platform gesture/focus behavior still require applicable consumer reconciliation and installed tests. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1774 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn find_row_fills_responsive_panel_without_a_fixed_width_cap()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1709 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn table_canvas_contains_every_staggered_cell_and_focus_padding()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1727 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn visual_context_selection_matches_rows_columns_and_reordered_ids()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1739 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn table_actions_are_gated_by_route_capabilities_selection_pending_and_lock()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1751 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn find_icon_is_centered_inside_stable_editor_slot()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1802 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn table_helpers_reserve_height_and_warn_only_for_actual_cautions()]`. -- Test-code evidence: `desktop/src/ui/shell.rs:392 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn table_scene_fits_small_dock_and_zooms_at_pointer()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1774 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn find_row_fills_responsive_panel_without_a_fixed_width_cap()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1709 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn table_canvas_contains_every_staggered_cell_and_focus_padding()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1727 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn visual_context_selection_matches_rows_columns_and_reordered_ids()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1739 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn table_actions_are_gated_by_route_capabilities_selection_pending_and_lock()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1751 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn find_icon_is_centered_inside_stable_editor_slot()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1802 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn table_helpers_reserve_height_and_warn_only_for_actual_cautions()]`. +- Test-code evidence: `desktop/src/ui/shell.rs:394 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn table_scene_fits_small_dock_and_zooms_at_pointer()]`. - Remaining: Issue #3: execute the canonical native tests and inspect actual desktop table/menu screenshots and pointer/keyboard behavior. Source guards and simulated snapshots are not installed-board or physical acceptance. ### Pealayer: `src/ui/dialog.rs` @@ -3293,12 +3565,12 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `8a396d67862578f8727e43a952e82120b20c9c582ebcc5366ab0968ba8f704d8`. - Target: `desktop/src/ui/preferences.rs:364 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=let action_height = super::control_density::height(ui).max(dialog::ACTION_HEIGHT);]`. -- Target: `desktop/src/ui/cell_table.rs:725 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn manager_selector(]`. -- Target: `firmware/esp/src/WebUi.h:183 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=/* Fixed manager slots:]`. +- Target: `desktop/src/ui/cell_table.rs:725 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn manager_selector(]`. +- Target: `firmware/esp/src/WebUi.h:183 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=/* Fixed manager slots:]`. - Target: `desktop/src/ui/effect_controls.rs:66 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=pub fn mode_picker(]`. -- Target: `desktop/src/ui/panel_details.rs:161 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=pub fn correction_rules(]`. -- Target: `desktop/src/ui/panel_details.rs:331 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=pub fn network(]`. -- Target: `desktop/src/state_appearance.rs:51 [UTF8-LF sha256=48f32be4b4343519332f5dd4f93874b09a08362e5577308b3838072be62a7f71; anchor=pub fn controls(]`. +- Target: `desktop/src/ui/panel_details.rs:161 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=pub fn correction_rules(]`. +- Target: `desktop/src/ui/panel_details.rs:348 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=pub fn network(]`. +- Target: `desktop/src/state_appearance.rs:52 [UTF8-LF sha256=567e65fa7d3e0e7ad7ef508b7944c0f861b376fd348031dd105ad2df94ece5eb; anchor=pub fn controls(]`. - Retained: Lamp-background/hover correction retains actual navigation_control and stable_toggle fixed allocation, centered text and inside-stroke contract, reread directly in this scoped pass. Rounded/semantic circle/hexagon faces optionally use exact board-owned state RGB with contrast-selected ink; unknown stays neutral and sampled six-pixel frames stay unmodified. Shared hover edge strengthens contrast instead of replacing state edge with darker generic stroke. Selection/focus identity and hit geometry remain stable. Painter/contrast tests are source proof pending execution/capture. - Retained: Read all 952 donor lines through EOF. navigation_control's fixed allocation, clipped centered text and inside-stroke painting are adapted to manager_selector. Compact cell/row/state slots use the existing shared neutral animated cell_surface; state-dependent Button Frames cannot resize their text/content geometry. Selected widget metadata is retained explicitly. - Retained: Current complete 1236-line donor and complete mapped Pakar dialog.rs were reread by the status/front-panel agent; historical bounded geometry, inside-stroke navigation, equal action sizing, RTL adjustments, numeric modifier/clipboard/wheel and scrolling mappings remain intact. This is source comparison, not native test execution. @@ -3309,11 +3581,11 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `singleline_text_edit/workspace_overlay_layer/preferred_host/centered_default_rect/bounded_geometry/opaque frame/action buttons/action rows/navigation detail/section/compact_row/setting_row/scroll_column/fit_scroll and their donor tests`. Disposition: **pending**. Reason: Existing imported dialog helpers and the newly mapped effect/network/correction/state-appearance consumers are present. This scoped pass does not certify every call site or host/resize/action/localization test; full generic consumer and test reconciliation remains issue #3 work. - Omitted/replaced code: `numeric_stepper/numeric_adjustment/adjust_numeric/numeric_slider_wheel/parse_numeric_paste/receive_numeric_paste/numeric_context_menu and seven pointer/paste/wheel tests`. Disposition: **pending**. Reason: Helpers already exist in the imported module; bounded input geometry, paste ownership, wheel tail suppression and context behavior need their actual donor runtime tests restored and controller settings coverage. Hover selector acceptance is not numeric-control parity. - Omitted/replaced code: `Current stable_toggle, fixed-slot close_button, confirmation_modal AlertDialog focus restoration/top-modal Escape/non-destructive backdrop, inline_caption_edit and density-aware singleline_text_edit`. Disposition: **pending**. Reason: Density-aware singleline_text_edit is adapted, but the generic stable_toggle, fixed-slot close_button, confirmation-modal focus/top-modal Escape/backdrop and inline-caption behavior still need complete consumer/test reconciliation. Current custom Preferences/device dialogs are not certified equivalent to those donor helpers. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1950 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn hover_strengthens_existing_cell_edge_without_replacing_selection_identity()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:1978 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn opted_in_semantic_shapes_use_exact_fill_without_moving_the_hit_slot()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1950 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn hover_strengthens_existing_cell_edge_without_replacing_selection_identity()]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:1978 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn opted_in_semantic_shapes_use_exact_fill_without_moving_the_hit_slot()]`. - Test-code evidence: `desktop/src/ui/cell_pixels.rs:292 [UTF8-LF sha256=547feb8158f8436ce6b3664dd90fcd59affdcce21dc7f9be273d2945e7424da6; anchor=fn pixel_preview_hover_preserves_rgb_and_fixed_outline_width()]`. -- Test-code evidence: `desktop/src/ui/cell_table.rs:2102 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn manager_hit_slots_text_and_wrap_never_follow_hover_or_stroke_width()]`. -- Test-code evidence: `tests/web_cell_table_test.mjs:12 [UTF8-LF sha256=22cbf98740547bde586d0faf786238135e2745130dde39c978d0de1336f4f63a; anchor=Manager must not allocate state-dependent selectable Frames]`. +- Test-code evidence: `desktop/src/ui/cell_table.rs:2102 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn manager_hit_slots_text_and_wrap_never_follow_hover_or_stroke_width()]`. +- Test-code evidence: `tests/web_cell_table_test.mjs:12 [UTF8-LF sha256=ac7e5a9d0b7af5bcacad0949c10e4dc9d08377364c05495593585883b566d6b1; anchor=Manager must not allocate state-dependent selectable Frames]`. - Test-code evidence: `desktop/src/ui/effect_controls.rs:472 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn fixed_role_board_allocates_no_mode_card_or_spacer()]`. - Test-code evidence: `desktop/src/ui/effect_controls.rs:512 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn choices_keep_equal_geometry_when_hovered_selected_or_disabled()]`. - Test-code evidence: `desktop/src/ui/effect_controls.rs:484 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn reset_remains_available_for_a_single_first_wins_mode_only_when_live()]`. @@ -3321,12 +3593,12 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Test-code evidence: `desktop/src/ui/effect_controls.rs:303 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn brightness_drag_retains_draft_across_observations_and_commits_once_on_both_routes()]`. - Test-code evidence: `desktop/src/ui/effect_controls.rs:343 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn brightness_cancel_disconnect_and_owner_changes_never_commit_old_gestures()]`. - Test-code evidence: `desktop/src/ui/effect_controls.rs:391 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn brightness_keyboard_commits_once_and_unknown_observation_remains_non_mutating()]`. -- Test-code evidence: `desktop/src/ui/panel_details.rs:449 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=fn feature_chips_wrap_as_whole_slots_without_vertical_labels_or_overflow()]`. -- Test-code evidence: `desktop/src/ui/panel_details.rs:593 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=fn network_and_corrections_stay_bounded_and_passive_in_narrow_panels()]`. -- Test-code evidence: `desktop/src/ui/panel_details.rs:512 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=fn scoped_action_rows_fit_narrow_cards_and_keep_wide_rows_compact()]`. -- Test-code evidence: `desktop/src/ui/panel_details.rs:572 [UTF8-LF sha256=12e56243648941509435a13a165c89e1abb438710ccb1d311c78df8fb052096a; anchor=fn ethernet_uses_the_board_link_up_observation()]`. -- Test-code evidence: `desktop/src/state_appearance.rs:319 [UTF8-LF sha256=48f32be4b4343519332f5dd4f93874b09a08362e5577308b3838072be62a7f71; anchor=fn appearance_cards_wrap_within_docked_panels_without_sending_on_render()]`. -- Test-code evidence: `desktop/src/state_appearance.rs:237 [UTF8-LF sha256=48f32be4b4343519332f5dd4f93874b09a08362e5577308b3838072be62a7f71; anchor=fn unavailable_connection_disables_appearance_and_preview_without_hiding_them()]`. +- Test-code evidence: `desktop/src/ui/panel_details.rs:466 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=fn feature_chips_wrap_as_whole_slots_without_vertical_labels_or_overflow()]`. +- Test-code evidence: `desktop/src/ui/panel_details.rs:610 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=fn network_and_corrections_stay_bounded_and_passive_in_narrow_panels()]`. +- Test-code evidence: `desktop/src/ui/panel_details.rs:529 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=fn scoped_action_rows_fit_narrow_cards_and_keep_wide_rows_compact()]`. +- Test-code evidence: `desktop/src/ui/panel_details.rs:589 [UTF8-LF sha256=d52b713fbf32ae3f9cf0c550f0ff172d2bc5450eeb985ab8a0ce1e0a7edd2d0d; anchor=fn ethernet_uses_the_board_link_up_observation()]`. +- Test-code evidence: `desktop/src/state_appearance.rs:337 [UTF8-LF sha256=567e65fa7d3e0e7ad7ef508b7944c0f861b376fd348031dd105ad2df94ece5eb; anchor=fn appearance_cards_wrap_within_docked_panels_without_sending_on_render()]`. +- Test-code evidence: `desktop/src/state_appearance.rs:255 [UTF8-LF sha256=567e65fa7d3e0e7ad7ef508b7944c0f861b376fd348031dd105ad2df94ece5eb; anchor=fn unavailable_connection_disables_appearance_and_preview_without_hiding_them()]`. - Remaining: Execute and record current native panel/painter geometry tests, then capture/deploy through guarded workflows. The earlier cache-budget blockage is historical, not asserted current. Source/DOM-double or native offscreen tests are not installed visual or physical acceptance. - Remaining: Complete the named generic dialog call-site/test reconciliation in issue #3; no whole-file behavioral equivalence or whole-project superiority claim. @@ -3335,11 +3607,11 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `134f1613df05c9db44f8ccd178981a8eca4fb6c254ced6466229afca66b40ee9`. - Target: `desktop/src/ui/dropdown.rs:398 [UTF8-LF sha256=92376984276d5bc4714cd59270ba839fa3b00b66d68ba9e8a03e7024870af4d8; anchor=pub trait DropdownUiExt]`. -- Target: `desktop/src/ui/cell_table.rs:763 [UTF8-LF sha256=d010ece42e30b3295dda9e07e4a3ba4509edf7d40dfe4d800801bf347b7d813a; anchor=fn cell_order_controls(]`. -- Target: `desktop/src/ui/connection_controls.rs:84 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=pub fn draw(]`. +- Target: `desktop/src/ui/cell_table.rs:763 [UTF8-LF sha256=a895f275243c9079783aa7f0165bac1d21f692e84b30f8aaeed4fbcc52cf8a19; anchor=fn cell_order_controls(]`. +- Target: `desktop/src/ui/connection_controls.rs:90 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=pub fn draw(]`. - Target: `desktop/src/ui/preferences.rs:443 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=super::dropdown::ComboBox::from_id_salt("theme")]`. -- Target: `desktop/src/ui/device_settings.rs:151 [UTF8-LF sha256=6988aa130e2f23e44b0b1fbb3f77e7ae4f221babcc4c3ed21ce0726e06c01ed3; anchor=super::dropdown::ComboBox]`. -- Target: `desktop/src/ui/shell.rs:2851 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=super::dropdown::ComboBox]`. +- Target: `desktop/src/ui/device_settings.rs:151 [UTF8-LF sha256=c087cbbde3bfc194b6002b10a3b63bb16ac4e253ef33d1a6f951ab2e566a42f6; anchor=super::dropdown::ComboBox]`. +- Target: `desktop/src/ui/shell.rs:2976 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=super::dropdown::ComboBox]`. - Target: `desktop/src/ui/effect_controls.rs:11 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=pub fn backend_picker(]`. - Target: `desktop/src/led_backend.rs:39 [UTF8-LF sha256=34380845ae7616e7d2ae6718f0bc3ff20c2ef36380c7a3374ed9e1f394cb5978; anchor=pub fn finish_network(]`. - Retained: Lamp-background checkpoint scoped rereview of actual ComboBox::show_ui/dropdown_style/menu_ui and unchanged imported module. Preferences theme/density and shell transition selectors retain shared geometry, keyboard/selection registration and closed-menu ownership. Workspace checkbox does not replace those selectors. Only inspected consumer hashes are rebound; earlier complete-read provenance remains historical. @@ -3355,7 +3627,7 @@ Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cd - Test-code evidence: `desktop/src/ui/dropdown.rs:549 [UTF8-LF sha256=92376984276d5bc4714cd59270ba839fa3b00b66d68ba9e8a03e7024870af4d8; anchor=fn dropdown_search_keeps_text_focus_until_arrow_enters_choices()]`. - Test-code evidence: `desktop/src/ui/dropdown.rs:673 [UTF8-LF sha256=92376984276d5bc4714cd59270ba839fa3b00b66d68ba9e8a03e7024870af4d8; anchor=fn dropdown_color_rows_are_full_width_equal_height_and_hover_stable()]`. - Test-code evidence: `desktop/src/ui/dropdown.rs:445 [UTF8-LF sha256=92376984276d5bc4714cd59270ba839fa3b00b66d68ba9e8a03e7024870af4d8; anchor=fn from_label_keeps_locked_egui_trigger_identity()]`. -- Test-code evidence: `desktop/src/ui/connection_controls.rs:262 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=fn one_contextual_control_lazy_usb_scan_and_busy_guard()]`. +- Test-code evidence: `desktop/src/ui/connection_controls.rs:266 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=fn one_contextual_control_lazy_usb_scan_and_busy_guard()]`. - Test-code evidence: `desktop/src/ui/dropdown.rs:616 [UTF8-LF sha256=92376984276d5bc4714cd59270ba839fa3b00b66d68ba9e8a03e7024870af4d8; anchor=fn dropdown_unregistered_editor_retains_arrow_keys()]`. - Test-code evidence: `tests/native_dropdown_test.mjs:1 [UTF8-LF sha256=c8ac060ff9ab0355f45d68b1cd503c208155632423a262f93f800249ba922662; anchor=Wiring/provenance checks]`. - Test-code evidence: `desktop/src/ui/effect_controls.rs:235 [UTF8-LF sha256=da148d2876de05674cc876ed167df5518bb294698090ec5d3fd5a6ad39587dfc; anchor=fn backend_control_is_observation_only_without_an_explicit_choice()]`. @@ -3380,8 +3652,8 @@ Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1c Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `811a48221c47c903c1d70ca904ead90a7a6b7f5ee0ba9a5f26a7e60dd30a75c3`. -- Target: `desktop/src/ui/i18n.rs:6 [UTF8-LF sha256=21c1e90d6eb7057d4774f21a2b8524fb20411bceed3cb050db795477974d3de1; anchor=pub(crate) fn configure_ui_fonts]`. -- Target: `desktop/src/ui/dialog.rs:18 [UTF8-LF sha256=b001d9fac54ee9d364479f3cb2d7ed5f5eb01b4c8ed72169a20641e8701610b2; anchor=pub fn singleline_text_edit]`. +- Target: `desktop/src/ui/i18n.rs:6 [UTF8-LF sha256=0f06c9f2f84e5710fe5bbea7a83221c0c527e61ba52ab0b63ee032974ced6739; anchor=pub(crate) fn configure_ui_fonts]`. +- Target: `desktop/src/ui/dialog.rs:18 [UTF8-LF sha256=854b8e1ca332650ad9964cb3e36f6a6304607502e953fa1bdf660bafb00f117f; anchor=pub fn singleline_text_edit]`. - Target: `desktop/src/ui/preferences.rs:448 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=AppLanguage::Persian]`. - Target: `docs/PERSIAN-PARITY.md:48 [UTF8-LF sha256=d1f873fb589b406316d77e9ca2fcaf652fe8773b1534063504fc1ab516b506da; anchor=## Exact source-to-target mapping]`. - Retained: Lamp-background checkpoint scoped rereview of logical-Unicode visual_text, script/input-alignment and directional layout helpers. Two application-owned preference strings have Persian translations; board names, protocol values and sampled data stay untranslated. Current preference/i18n hashes rebound, not whole-dictionary parity. @@ -3393,8 +3665,8 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `Media playback, cue/timeline reveal and keyframe-specific dictionary keys`. Disposition: **not-applicable**. Reason: They describe explicitly excluded media business; no corresponding controller surface exists. No generic UI/tooling capability is excluded on that basis. - Omitted/replaced code: `Manage folders, Manage folder, New folder, Expand folders, Collapse folders, Move to folder, Delete folder, Confirm delete, Channels will be moved to Ungrouped, pending folder change and attached-board folder configuration copy`. Disposition: **pending**. Reason: Generic grouping behavior is useful but not yet integrated with actual board cell/group contracts. No fake folder actions or completion claim; retain on issue #4 for real grouping and localized acceptance. - Omitted/replaced code: `Separate ttf_parser coverage assertions and exhaustive native surface translations`. Disposition: **pending**. Reason: Avoid introducing a duplicate font parser dependency: actual renderer glyph/contextual and font-retention tests pass in 8849ab8 Windows CI after headless output repair. The run completes 114 passed/1 unrelated menu-label failure; no abort. Additional Unicode, production rendering, device-settings and status/transport copy remain unaccepted. -- Test-code evidence: `desktop/src/ui/i18n.rs:1555 [UTF8-LF sha256=21c1e90d6eb7057d4774f21a2b8524fb20411bceed3cb050db795477974d3de1; anchor=fn contextual_persian_in_labels_and_monospace_inputs]`. -- Test-code evidence: `desktop/src/ui/i18n.rs:1509 [UTF8-LF sha256=21c1e90d6eb7057d4774f21a2b8524fb20411bceed3cb050db795477974d3de1; anchor=fn changing_language_preserves_widget_fonts]`. +- Test-code evidence: `desktop/src/ui/i18n.rs:1559 [UTF8-LF sha256=0f06c9f2f84e5710fe5bbea7a83221c0c527e61ba52ab0b63ee032974ced6739; anchor=fn contextual_persian_in_labels_and_monospace_inputs]`. +- Test-code evidence: `desktop/src/ui/i18n.rs:1513 [UTF8-LF sha256=0f06c9f2f84e5710fe5bbea7a83221c0c527e61ba52ab0b63ee032974ced6739; anchor=fn changing_language_preserves_widget_fonts]`. - Test-code evidence: `tests/localization_test.mjs:42 [UTF8-LF sha256=da500a29e5ebdb93f52cf275ec1e77f34881d5a66e729f99b67d3e29e1472249; anchor=Peer text identical to a dictionary key]`. - Remaining: Inspect real deployed Persian inputs/menus/dialogs and finish application-copy/generic grouping parity. Actual 8849ab8 font-retention/shaping tests pass, but full native release remains open with one menu-label assertion; local cache budget remains enforced. @@ -3403,7 +3675,10 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `cd56a5849a0be621d8472b440c9cbac8b91bcaf603d228cb6148bba1eb554fc9`. - Target: `desktop/src/ui/icons.rs:581 [UTF8-LF sha256=cd5fe34a628b22220e9be47bf4de262e5e8eeec8e7c045f6dde76414c511a40b; anchor=pub fn searchable_workspace_icon_picker(]`. -- Target: `desktop/src/ui/shell.rs:4188 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=icons::searchable_workspace_icon_picker]`. +- Target: `desktop/src/ui/shell.rs:4404 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=icons::searchable_workspace_icon_picker]`. +- Target: `desktop/src/ui/menu.rs:7 [UTF8-LF sha256=80c2a5130e97fd8dfe62206034a48347b691fe06bb65998049937afeee37f602; anchor=pub fn submenu(]`. +- Target: `desktop/src/ui/key_boxes.rs:56 [UTF8-LF sha256=eb5a980c1b2cf7498e43bf79510603dec52cd5e922f29cf0bcbb81d5d56b89b8; anchor=fn context_menu(]`. +- Retained: 2026-10-27 scoped submenu rereview: actual icons::submenu Phosphor right_text(CARET_RIGHT) and SubMenuButton implementation imported into desktop/src/ui/menu.rs and consumed by the key-box Selection and Panels menus. The existing complete-file receipt is not promoted to full application parity. - Retained: Lamp-background checkpoint scoped rereview of actual searchable_workspace_icon_picker and shell workspace picker call: same presets, stable stored name, fallback, localized search and shared native picker remain. New IPC preference does not change icon behavior; only inspected shell consumer hash refreshed. - Retained: Fully reread all1190 current donor lines through EOF. Production Phosphor vocabulary, stable names/kind fallback, searchable list/grid picker, UTF-8-safe match highlighting, bounded popups and disclosure/submenu helpers remain retained. New catalog/density/localized-title/fixed-header-slot behavior is separately pending, not certified by the prior eleven tests. - Retained: Actual donor regression tests restored: eleven tests, including interactive egui-frame search/select/reopen, both themes and popup width. @@ -3412,6 +3687,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `every_native_icon_editor_uses_the_shared_searchable_picker donor-only editor path list`. Disposition: **replaced**. Reason: That assertion references Pealayer media/4D editor files not present in this product. Replaced only its call-site assertion with Pakar's actual workspace editor; the other ten tests were imported. This does not excuse missing generic editor features elsewhere. - Omitted/replaced code: `DropdownUiExt, dropdown_choice, register_choice and dropdown::ComboBox in searchable icon pickers`. Disposition: **replaced**. Reason: Actual donor search-focus registration, list choices, custom grid registration and lifecycle wrapper are imported against the fully tracked dropdown source. Product context IDs are adapted; all eleven existing icon tests remain. Native execution is separate proof, and UI-wide picker consumer parity remains pending. - Test-code evidence: `desktop/src/ui/icons.rs:839 [UTF8-LF sha256=cd5fe34a628b22220e9be47bf4de262e5e8eeec8e7c045f6dde76414c511a40b; anchor=fn pakar_workspace_editor_uses_the_actual_shared_searchable_picker()]`. +- Test-code evidence: `desktop/src/ui/key_boxes.rs:771 [UTF8-LF sha256=eb5a980c1b2cf7498e43bf79510603dec52cd5e922f29cf0bcbb81d5d56b89b8; anchor=fn box_clicks_reuse_selection_and_secondary_menu_never_actuates()]`. - Test-code evidence: `desktop/src/ui/icons.rs:968 [UTF8-LF sha256=cd5fe34a628b22220e9be47bf4de262e5e8eeec8e7c045f6dde76414c511a40b; anchor=fn icon_picker_search_click_filter_select_and_reopen_work_in_both_forms()]`. - Remaining: Imported compact/control picker helpers are not all used by current panels; UI-wide application/action parity needs separate file reviews. - Remaining: Shared dropdown dependency now has a complete-read comparison; preserve remaining generic consumer contracts and verify native runtime without claiming whole-file parity. @@ -3420,9 +3696,9 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `a0196878c7b76b898d0a5791b122181d9045d3fbbe9f56de52bc43242edf2d9d`. -- Target: `desktop/src/ui/menu.rs:9 [UTF8-LF sha256=fe951002f9cf6daff34dcce35ac05ab065aea58ae985553dfa435339f28baee4; anchor=pub fn top_menu_button(]`. -- Target: `desktop/src/ui/shell.rs:1408 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=menu::top_menu_button(ui, crate::ui::i18n::text("File")]`. -- Target: `desktop/src/ui/connection_controls.rs:84 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=pub fn draw(]`. +- Target: `desktop/src/ui/menu.rs:15 [UTF8-LF sha256=80c2a5130e97fd8dfe62206034a48347b691fe06bb65998049937afeee37f602; anchor=pub fn top_menu_button(]`. +- Target: `desktop/src/ui/shell.rs:1426 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=menu::top_menu_button(ui, crate::ui::i18n::text("File")]`. +- Target: `desktop/src/ui/connection_controls.rs:90 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=pub fn draw(]`. - Target: `docs/NATIVE-MENU-COMPARISON.md:32 [UTF8-LF sha256=7319a38240f9c23a43f5a18c2f20d05b0c9d5ff273cf0e8f28b2ba98aa1aabd8; anchor=## Exact donor menu mapping and remaining decisions]`. - Retained: Actual top_menu_button body imported with only application state ID/public visibility adaptation; all four production menu headings use active-menu sibling hover switching. - Retained: Actual Button::shortcut_text idiom replaces space-padded primary labels in all six shortcut rows; separate right-aligned muted atoms are supplied by the same egui 0.36.2 dependency. Existing dispatch/Phosphor submenu retained; user-requested height policy unchanged. @@ -3436,8 +3712,8 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `draw connection requested/connecting/connected status/manual discovery and remote connection/browser/download actions`. Disposition: **pending**. Reason: Pakar has connection/typed actions and firmware tools, but full donor lifecycle/readiness/authenticated endpoint adaptation is not compared or accepted. Media remote browser cannot be blindly substituted for firmware management. - Omitted/replaced code: `DropdownUiExt selected workspace/language/direction entries and new unified dropdown lifecycle`. Disposition: **pending**. Reason: The tracked shared dropdown module is now fully read and imported, with native consumers and donor tests. This does not implement every generic consumer contract in this file: render-rate policy, workspace/language/direction actions and complete board EEPROM enum ownership remain independently pending. Native execution and visual acceptance are reported separately. - Omitted/replaced code: `Current header_endpoint_combo fixed 154-pixel clipped trigger/full 240-pixel popup and density/font/disabled-hover geometry tests`. Disposition: **pending**. Reason: Pakar now has one unified method trigger and selected-method details, not independent route popups. It reuses240px popup choices but not the donor154px endpoint trigger/dynamic-density geometry assertions. Compare actual long-text/font/scale layouts before claiming geometry parity; no duplicate or pretend-ready control should be added to match labels. -- Test-code evidence: `desktop/src/ui/menu.rs:69 [UTF8-LF sha256=fe951002f9cf6daff34dcce35ac05ab065aea58ae985553dfa435339f28baee4; anchor=fn shortcuts_are_separate_muted_right_aligned_atoms_in_both_themes()]`. -- Test-code evidence: `desktop/src/ui/menu.rs:192 [UTF8-LF sha256=fe951002f9cf6daff34dcce35ac05ab065aea58ae985553dfa435339f28baee4; anchor=fn sibling_hover_switches_only_an_already_open_menu_and_clears_closed_state()]`. +- Test-code evidence: `desktop/src/ui/menu.rs:75 [UTF8-LF sha256=80c2a5130e97fd8dfe62206034a48347b691fe06bb65998049937afeee37f602; anchor=fn shortcuts_are_separate_muted_right_aligned_atoms_in_both_themes()]`. +- Test-code evidence: `desktop/src/ui/menu.rs:198 [UTF8-LF sha256=80c2a5130e97fd8dfe62206034a48347b691fe06bb65998049937afeee37f602; anchor=fn sibling_hover_switches_only_an_already_open_menu_and_clears_closed_state()]`. - Test-code evidence: `tests/native_menu_test.mjs:12 [UTF8-LF sha256=8d7540f4c8abd4a39414d18cd3bedc70bbb8ab05af4077c206a4ce11beea6a01; anchor=actual donor helper must match after application ID/visibility/whitespace adaptation]`. - Test-code evidence: `tests/native_render_lifecycle_test.mjs:31 [UTF8-LF sha256=655bb0a62d3144718b1b13d350fb7e416006b6c468e7e1445bbd2ebcb7c946dc; anchor=const compact=source=>source.replace]`. - Remaining: Restore remaining useful generic menu contracts above against actual dependencies; see exact function-level mapping in NATIVE-MENU-COMPARISON.md. Native CI paint/pointer tests and running-app visual acceptance are separate from source guards. @@ -3453,8 +3729,8 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `WebAppearanceState and donor native/Web shared-preferences assertions`. Disposition: **pending**. Reason: Pakar lacks the shared native/Web preferences contract. These are useful generic requirements, not media business logic; omitting them cannot be justified as superior. - Omitted/replaced code: `Donor default Native palette`. Disposition: **replaced**. Reason: Pakar chooses Studio as its product default. Both palettes remain available; this is a preference decision, not a measured improvement. - Test-code evidence: `desktop/src/ui/palette.rs:121 [UTF8-LF sha256=3d04f723252650661f9fdacd2e1929f3ad28fac4cd3dc40720bca32eac4f3a7c; anchor=fn donor_surface_text_roles_and_geometry_are_preserved_in_both_themes()]`. -- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:145 [UTF8-LF sha256=2930a5e706fa835f704f097c44e664903aeed7f11b63557408ae2a8411d66219; anchor=fn button_hit_slots_and_caption_ink_survive_every_interaction_state()]`. -- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:150 [UTF8-LF sha256=2930a5e706fa835f704f097c44e664903aeed7f11b63557408ae2a8411d66219; anchor=fn semantic_actions_preserve_inherited_zero_stroke_menu_geometry()]`. +- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:146 [UTF8-LF sha256=4985dc82110f0948c5153b5e9b7b472054b0f62d10a8e2dbb6a38a664848a6a1; anchor=fn button_hit_slots_and_caption_ink_survive_every_interaction_state()]`. +- Test-code evidence: `desktop/src/ui/button_geometry_tests.rs:151 [UTF8-LF sha256=4985dc82110f0948c5153b5e9b7b472054b0f62d10a8e2dbb6a38a664848a6a1; anchor=fn semantic_actions_preserve_inherited_zero_stroke_menu_geometry()]`. - Remaining: Embedded Web consumes the same JSON catalog through a stale-generation check. Complete mobile catalog use and live native/Web preference broadcast/readback; pure donor host-resolution tests do not prove connected-host synchronization. ### Pealayer: `src/ui/preferences.rs` @@ -3465,8 +3741,8 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Target: `desktop/src/ui/preferences.rs:67 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn sync_connection_owner(]`. - Target: `desktop/src/ui/preferences.rs:96 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn sync_status_owner(]`. - Target: `desktop/src/ui/preferences.rs:315 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn connection_summary(]`. -- Target: `desktop/src/ui/device_settings.rs:54 [UTF8-LF sha256=6988aa130e2f23e44b0b1fbb3f77e7ae4f221babcc4c3ed21ce0726e06c01ed3; anchor=fn avr_page(]`. -- Target: `desktop/src/ui/shell.rs:2784 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=pub(super) fn draw_led_settings(]`. +- Target: `desktop/src/ui/device_settings.rs:54 [UTF8-LF sha256=c087cbbde3bfc194b6002b10a3b63bb16ac4e253ef33d1a6f951ab2e566a42f6; anchor=fn avr_page(]`. +- Target: `desktop/src/ui/shell.rs:2909 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=pub(super) fn draw_led_settings(]`. - Retained: Lamp-background preference uses existing Workspace editor and actual imported saved/current/preview baselines, explicit Save and Discard. Parent agent reread all 2308 donor lines; evidence pass independently inspected donor draft lifecycle and current Workspace/Save/Discard consumers. Optional setting defaults false for existing profiles, serializes without other changes and is enabled for requested user only through guarded app-local IPC. Open Preferences blocks remote application rather than overwriting drafts. Roundtrip and preview/discard tests are source proof, not device/deployment claims. - Retained: Fully reread all2308 actual donor lines through EOF, including draft lifecycle, helper ownership, control rendering and tests. Pakar retains saved/config/previewed baselines, live preview, dirty close confirmation and rail/body/footer layout; this is a narrow adaptation, not complete Preferences equivalence. - Retained: Adapted actual PreferencesDraft::sync_external_appearance triple-baseline merge to sync_connection_owner. External selected method/enabled/endpoint/user/port update all three baselines while preserving unrelated dirty edits. Preferences now shows only the selected method's summary and delegates clean drafts to the single canonical connection dialog; unrelated Save cannot restore stale connection owner fields. @@ -3479,7 +3755,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `Config-file open/copy/edit/folder menus, complete reload/import/export ownership, timed footer feedback and full save/preview failure lifecycle matrix`. Disposition: **pending**. Reason: Basic import/export and dirty Save/Discard behavior already exist, but do not implement the donor's complete picker/config authority, transient status and helper-exit matrix. Generic file-picker/control mechanisms remain useful, not excluded media business. - Omitted/replaced code: `Playback/track/subtitle/history/media-association options and mpv audio refresh`. Disposition: **not-applicable**. Reason: The requested controller is explicitly mpv-free and has no media playback domain. Generic preference lifecycle, navigation, picker and rendering mechanisms remain independently pending rather than excluded with those options. - Test-code evidence: `desktop/src/ui/preferences.rs:759 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn preferences_footer_stays_at_the_bottom_in_both_hosts_and_short_pages()]`. -- Test-code evidence: `desktop/src/config.rs:394 [UTF8-LF sha256=da1ad74dc53c474cb4b478cc0557c65e5daf2bc14fb9e97f88eaf5be76c0a862; anchor=fn cell_background_preference_is_opt_in_and_roundtrips_without_other_changes()]`. +- Test-code evidence: `desktop/src/config.rs:397 [UTF8-LF sha256=b84801a85e31a44b2691bd5f6ccefea0106d799952b0fb61e2d6162ee11b9414; anchor=fn cell_background_preference_is_opt_in_and_roundtrips_without_other_changes()]`. - Test-code evidence: `desktop/src/ui/preferences.rs:918 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn lamp_background_preview_and_discard_preserve_saved_configuration()]`. - Test-code evidence: `desktop/src/ui/preferences.rs:942 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn external_connection_owner_updates_all_baselines_without_losing_unrelated_drafts()]`. - Test-code evidence: `desktop/src/ui/preferences.rs:996 [UTF8-LF sha256=38bda5597ef69c27761de2bfc820e2d507c8fe29353d3e1f95c118a477d22448; anchor=fn status_bar_changes_survive_preferences_save_or_discard_without_erasing_drafts()]`. @@ -3492,15 +3768,15 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `e407025a1ee959230634f8350de20d58432313d6dd0f734ada735b8fc9fec4db`. -- Target: `desktop/src/ui/status_bar.rs:408 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn item_menu(]`. -- Target: `desktop/src/ui/status_bar.rs:36 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=impl<'de> Deserialize<'de> for Items]`. -- Target: `desktop/src/ui/status_bar.rs:296 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn layout_plan(]`. -- Target: `desktop/src/ui/status_bar.rs:186 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=pub struct Progress<'a>]`. -- Target: `desktop/src/ui/shell.rs:883 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn save_status_bar_preferences(]`. -- Target: `desktop/src/ui/shell.rs:1635 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=let firmware_progress = self.firmware_observation().filter(|p| p.active);]`. -- Target: `desktop/src/ui/connection_controls.rs:84 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=pub fn draw(]`. +- Target: `desktop/src/ui/status_bar.rs:409 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn item_menu(]`. +- Target: `desktop/src/ui/status_bar.rs:36 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=impl<'de> Deserialize<'de> for Items]`. +- Target: `desktop/src/ui/status_bar.rs:296 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn layout_plan(]`. +- Target: `desktop/src/ui/status_bar.rs:186 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=pub struct Progress<'a>]`. +- Target: `desktop/src/ui/shell.rs:891 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn save_status_bar_preferences(]`. +- Target: `desktop/src/ui/shell.rs:1653 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=let firmware_progress = self.firmware_observation().filter(|p| p.active);]`. +- Target: `desktop/src/ui/connection_controls.rs:90 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=pub fn draw(]`. - Target: `docs/CONNECTION-CONTROLS.md:1 [UTF8-LF sha256=002ee046e9c206a4cc241ad2b9033971b4933959d8e14f43958848403879f21d; anchor=# Front-panel transport dropdowns]`. -- Target: `firmware/esp/src/WebUi.h:2285 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function statusRgbOutput]`. +- Target: `firmware/esp/src/WebUi.h:2548 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function statusRgbOutput]`. - Target: `desktop/src/status_rgb.rs:46 [UTF8-LF sha256=3dbe61cf121e44b14fe047427fbe9d0b8e60735baa445ad3116310560631965e; anchor=pub fn retain(]`. - Target: `mobile/lib/status_rgb.dart:52 [UTF8-LF sha256=458f867c97d38107b37fc399dfc55f1ad2f786ce5e236a9a9b2c048d0b4fbfd0; anchor=class StatusRgbPreview]`. - Retained: Current complete source read through EOF independently by the status/front-panel and network/UI agents; selected target changes reviewed again during evidence integration. Actual per-item hide context menus, persistent visibility choices, bar-level restore menu and small truncating message labels remain adapted. Transport controls remain usable offline without claiming hardware readiness. @@ -3515,23 +3791,23 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `Pealayer hardcoded connection dot RGB`. Disposition: **replaced**. Reason: Pakar connection presentation uses current command-route authority, contrast-aware light/dark success colors and shared neutral/error visual roles; physical RGB is a separate measured-output widget, never a locally synthesized connection animation. - Omitted/replaced code: `UI render-rate toggle/context policy, dropdown-backed FPS menu, dividers/fonts/value ranges/chart history and complete host/download operation inventory`. Disposition: **pending**. Reason: Independent widget ordering/visibility and current board firmware progress are now implemented; measured UI/LED render-rate selection, bounded sensor history, user metric limits and the complete host/download manager inventory remain useful unported contracts. Do not add fake telemetry, initialize a service during paint or label media-FPS exclusions as completing generic performance monitoring. Native execution and visual acceptance remain separate evidence. - Omitted/replaced code: `Pointer drag-and-drop across anchored groups and drag-out-to-hide`. Disposition: **pending**. Reason: Move earlier/later, leading/trailing placement and hide/restore are available through persistent keyboard-accessible menus. The donor's original-render drag ghost, pointer grab offset and drag-out affordance are not implemented by those menu operations and are not claimed as parity. -- Test-code evidence: `desktop/src/ui/status_bar.rs:780 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn rgb_requires_observed_output_and_does_not_double_dim]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:570 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_legacy_visibility_migrates_all_combinations_without_reviving_telemetry()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:585 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_order_normalizes_unknown_duplicate_and_malformed_entries()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:603 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_placement_menu_click_moves_group_and_saves_once_without_panicking()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:628 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_connection_and_rgb_require_current_route_not_cached_peer_or_websocket()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:649 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_measured_progress_distinguishes_unknown_zero_and_completion()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:667 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_progress_and_focus_use_visible_accent_with_actual_dark_app_palette()]`. -- Test-code evidence: `desktop/src/main.rs:5881 [UTF8-LF sha256=cf5ff1b17fa6bb7d8e413dc710ec7562ab57a8e354211aa0b91ad7ebd36a1815; anchor=fn status_bar_startup_message_respects_disconnected_and_selected_route()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:700 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_real_egui_slots_stay_single_row_in_narrow_rtl_and_scaled_views()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:725 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_telemetry_changes_preserve_slot_bounds_and_never_request_save()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:740 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_buttons_dispatch_click_and_keyboard_without_passive_actions()]`. -- Test-code evidence: `desktop/src/ui/status_bar.rs:762 [UTF8-LF sha256=4fa040b2b78c8699a6b2e7eb1ab603804e7441ca9ab2c2aa98321a698e5e1465; anchor=fn status_overflow_retains_capability_gated_actions_and_arrow_keyboard_access()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:781 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn rgb_requires_observed_output_and_does_not_double_dim]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:571 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_legacy_visibility_migrates_all_combinations_without_reviving_telemetry()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:586 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_order_normalizes_unknown_duplicate_and_malformed_entries()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:604 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_placement_menu_click_moves_group_and_saves_once_without_panicking()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:629 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_connection_and_rgb_require_current_route_not_cached_peer_or_websocket()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:650 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_measured_progress_distinguishes_unknown_zero_and_completion()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:668 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_progress_and_focus_use_visible_accent_with_actual_dark_app_palette()]`. +- Test-code evidence: `desktop/src/main.rs:5908 [UTF8-LF sha256=29e2f9f22eb47f5e5508c8978f47e30fc8d670e61cc12bc7c5b6b75d56ab3f2c; anchor=fn status_bar_startup_message_respects_disconnected_and_selected_route()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:701 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_real_egui_slots_stay_single_row_in_narrow_rtl_and_scaled_views()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:726 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_telemetry_changes_preserve_slot_bounds_and_never_request_save()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:741 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_buttons_dispatch_click_and_keyboard_without_passive_actions()]`. +- Test-code evidence: `desktop/src/ui/status_bar.rs:763 [UTF8-LF sha256=1cca3d0a2080d33b1239ca22fa3f975c071c3a2c2c4f8cab8c1d87c136e744a9; anchor=fn status_overflow_retains_capability_gated_actions_and_arrow_keyboard_access()]`. - Test-code evidence: `tests/interface_settings_test.mjs:46 [UTF8-LF sha256=102d6c411b732bad8106dd09358da3df3989d1140aa77c87a884c8072c66c6f3; anchor=Actual emitted RGB is not brightness-scaled again]`. - Test-code evidence: `desktop/src/status_rgb.rs:157 [UTF8-LF sha256=3dbe61cf121e44b14fe047427fbe9d0b8e60735baa445ad3116310560631965e; anchor=fn compact_rgb_updates_only_the_matching_peer_and_newer_rgb_not_hardware_clock]`. - Test-code evidence: `mobile/test/status_rgb_test.dart:70 [UTF8-LF sha256=908b1d6f4f4832dea297e0e36b9ab87500754569b595938d3cecfb95870bb61f; anchor=physical RGB is observed not synthesized or double dimmed]`. - Test-code evidence: `tests/connection_controls_test.mjs:1 [UTF8-LF sha256=e3e741ccd20dcb88698a0a7cd6767b8f05921cc344e182e96a3104d6743dcc68; anchor=Source wiring guard only.]`. -- Test-code evidence: `desktop/src/ui/connection_controls.rs:373 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=fn lifecycle_is_one_explicit_action_and_disconnect_retains_port()]`. +- Test-code evidence: `desktop/src/ui/connection_controls.rs:378 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=fn lifecycle_is_one_explicit_action_and_disconnect_retains_port()]`. - Remaining: Native migration, progress, fixed-geometry, input and Preferences-owner checks passed (15 status tests plus the isolated viewport guard); final release framebuffer captures were inspected for connected, disconnected, warning and progress states. Candidate staged at executable SHA25602505274f29498991b2f9e693274f73e4b4cbe7b44738d1d03e2a547e3e00aab. Installed-runtime and human acceptance remain open; isolated fixtures do not prove live board health or physical RGB. Restore remaining generic status lifetime/readiness contracts in issues #3/#10. - Remaining: Shared dropdown dependency now has a complete-read comparison; preserve remaining generic consumer contracts and verify native runtime without claiming whole-file parity. @@ -3551,18 +3827,18 @@ Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cd Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `410d274f51e5e7db7ce81582d36166a97240b097e993cec5fee8707a95183e2f`. -- Target: `mobile/lib/output_panel.dart:23 [UTF8-LF sha256=811b7686b27f226c9173c13c35f04d7d296263e73cb54c319be0560caf910476; anchor=class OutputPanel]`. -- Target: `assets/web/feature-workspaces.js:108 [UTF8-LF sha256=e3ee781bcd00a2f84a06f893fa224244b8d555b7b9d956edc5cfe30588aa17c0; anchor=function diagnosticWorkspace(value)]`. -- Target: `web/index.html:2211 [UTF8-LF sha256=64ff59a7b22d89162c426e99ee8a6f0fd9a947c58143a8d76d462b5ebca2f601; anchor=function workspaceDiagnosticDraft()]`. -- Target: `mobile/test/output_panel_test.dart:196 [UTF8-LF sha256=d40cd2b5189af50d1e862629cae0facd6169d7151fbccb0325564a368312b4b6; anchor=Output order is bounded, durable and independent of table selection]`. -- Target: `mobile/lib/table_workspace.dart:13 [UTF8-LF sha256=1f21007e69778ddbedb34a1c392a152693f5dd742aae185019e6dbc1dc2b08a5; anchor=class TableWorkspace]`. -- Target: `mobile/lib/main.dart:894 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=Future _saveWorkspaces()]`. -- Target: `desktop/src/capabilities.rs:78 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=pub struct Presentation]`. -- Target: `desktop/src/ui/shell.rs:1052 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=fn synchronize_context]`. -- Target: `desktop/src/ui/layout.rs:231 [UTF8-LF sha256=8f46efe56d3579c0aeb1fe177ef76c0f548041a0af04a30f98f0ddbfc23202cc; anchor=presentation_context()]`. -- Target: `desktop/src/native_workspace.rs:151 [UTF8-LF sha256=5003829240d453d4917fa4e07dc74474b999a8d4bf9adcf661b36334b550da60; anchor=pub struct Store]`. -- Target: `desktop/src/config.rs:201 [UTF8-LF sha256=da1ad74dc53c474cb4b478cc0557c65e5daf2bc14fb9e97f88eaf5be76c0a862; anchor=pub native_workspaces]`. -- Target: `mobile/lib/table_workspace.dart:247 [UTF8-LF sha256=1f21007e69778ddbedb34a1c392a152693f5dd742aae185019e6dbc1dc2b08a5; anchor=class FrontPanelWorkspace]`. +- Target: `mobile/lib/output_panel.dart:27 [UTF8-LF sha256=3cf8895bbbdfc82cb7f4c12ed8e1895623099975722753d5dc722d3eb7ebe9c3; anchor=class OutputPanel]`. +- Target: `assets/web/feature-workspaces.js:108 [UTF8-LF sha256=93f24fd4008ef69eb247dd02d084a531a04b1dffaa86d6b651df43b5b6c3f33a; anchor=function diagnosticWorkspace(value)]`. +- Target: `web/index.html:2471 [UTF8-LF sha256=783ffe5a5dbe4b96c898cb896437247749f29bb8c8d2fecbb9659d5fae243b27; anchor=function workspaceDiagnosticDraft()]`. +- Target: `mobile/test/output_panel_test.dart:411 [UTF8-LF sha256=b75b25f46b391a84c0400f08361c4c520e2fda547e95f8b9030078f7f281e317; anchor=Output order is bounded, durable and independent of table selection]`. +- Target: `mobile/lib/table_workspace.dart:13 [UTF8-LF sha256=02445683146ff2b3bdca50bfa8d1aa1dedb94261c361613fdb545e7ca4f7c83e; anchor=class TableWorkspace]`. +- Target: `mobile/lib/main.dart:897 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=Future _saveWorkspaces()]`. +- Target: `desktop/src/capabilities.rs:78 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=pub struct Presentation]`. +- Target: `desktop/src/ui/shell.rs:1062 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=fn synchronize_context]`. +- Target: `desktop/src/ui/layout.rs:245 [UTF8-LF sha256=b5cf537aa8ff41c7302ed081a9c5d0821af271c3042b447cceb3fec8a9d70300; anchor=presentation_context()]`. +- Target: `desktop/src/native_workspace.rs:158 [UTF8-LF sha256=15688543f8999613f064f17e60d453f50d59a05c564e927379fb86d50bc4f0d8; anchor=pub struct Store]`. +- Target: `desktop/src/config.rs:201 [UTF8-LF sha256=b84801a85e31a44b2691bd5f6ccefea0106d799952b0fb61e2d6162ee11b9414; anchor=pub native_workspaces]`. +- Target: `mobile/lib/table_workspace.dart:268 [UTF8-LF sha256=02445683146ff2b3bdca50bfa8d1aa1dedb94261c361613fdb545e7ca4f7c83e; anchor=class FrontPanelWorkspace]`. - Target: `desktop/src/workspace_layout.rs:6 [UTF8-LF sha256=e7682600d1c90ee1805b3f70838d934bfd23de795ae50fa69599c034c88906db; anchor=pub struct WorkspaceLayout]`. - Retained: Complete live donor read through EOF at matching original-byte hash. Adapt the actual stable-ID profile_choices/draw ownership rule to board-keyed Flutter workspaces, rather than connection-attempt keys or telemetry-dependent widget lifetime. User selection/search/effect/indicator choice and both scroll axes survive transient leases, reboot, explicit reconnect and bounded background UI-cache restoration; snapshots never persist a command, authorization or observed device state. - Retained: Reread the same complete live donor at unchanged SHA256 for the Android screen-off regression. Actual user changes now notify a bounded debounced UI snapshot writer, including indicators, multiple selection, transition, Find/selection, activity and both scroll axes. One ordered coalescing writer prevents late old saves, skips identical snapshots and permits retry after failure. Reattachment uses the latest actual offsets rather than stale scroll-end PageStorage values. Healthy sockets remain open across backgrounding; a silent pre-suspend socket gets a 750 ms read-probe recovery without resetting the workspace or replaying commands. Colour actions and selection controls are separate ordered rows. @@ -3577,17 +3853,17 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `All donor profile dialog/tab presets, media-specific panels and workspace action parity`. Disposition: **pending**. Reason: Stable identity and transient-layout preservation are a scoped adaptation, not all donor profile editor/defaults/import-export UI. Media-only panels remain outside this controller; generic profile actions still need exact comparisons. - Omitted/replaced code: `Unsaved native privileged network/cloud/AVR form drafts`. Disposition: **pending**. Reason: They remain intentionally invalidated on peer revocation until durable public-only patches can be rebased against fresh baseline/secret ownership. General per-peer workspace controls and safe presentation drafts are persisted; this is not all editor-draft parity. - Test-code evidence: `mobile/test/workspace_lifecycle_test.dart:528 [UTF8-LF sha256=537941477dab0a2460cf42aab9220a7baf8d1aecf36eb140b21179674985071e; anchor=Workspace follows board identity]`. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:302 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Live observations do not trigger durable writes]`. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:320 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Live observations do not trigger durable writes]`. - Test-code evidence: `tests/web_workspace_test.mjs:124 [UTF8-LF sha256=e35df2215cf2802e9e58896204d4c44dbb12225411a7c511808f47af9679d7cf; anchor=A not-yet-attached feature owner preserves the existing scoped draft]`. -- Test-code evidence: `desktop/src/capabilities.rs:440 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=fn presentation_retains_only_prior_acks_without_reviving_live_authority]`. -- Test-code evidence: `desktop/src/capabilities.rs:502 [UTF8-LF sha256=543af19c9516121164be806dda9967f1437a77b0fc3c9bb1aa556e6449345503; anchor=fn measured_panel_height_preserves_scroll_only_for_same_board_panel_and_width]`. -- Test-code evidence: `desktop/src/native_workspace.rs:414 [UTF8-LF sha256=5003829240d453d4917fa4e07dc74474b999a8d4bf9adcf661b36334b550da60; anchor=fn workspace_roundtrip_preserves_unified_selection_drafts_and_scroll_without_authority]`. +- Test-code evidence: `desktop/src/capabilities.rs:442 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=fn presentation_retains_only_prior_acks_without_reviving_live_authority]`. +- Test-code evidence: `desktop/src/capabilities.rs:504 [UTF8-LF sha256=fe68fb6d4d5262079bae60446dd4dfbfc8f69ecce59cd7b12084f7d2cf6eb2a7; anchor=fn measured_panel_height_preserves_scroll_only_for_same_board_panel_and_width]`. +- Test-code evidence: `desktop/src/native_workspace.rs:425 [UTF8-LF sha256=15688543f8999613f064f17e60d453f50d59a05c564e927379fb86d50bc4f0d8; anchor=fn workspace_roundtrip_preserves_unified_selection_drafts_and_scroll_without_authority]`. - Test-code evidence: `mobile/test/workspace_lifecycle_test.dart:135 [UTF8-LF sha256=537941477dab0a2460cf42aab9220a7baf8d1aecf36eb140b21179674985071e; anchor=Front-panel]`. - Test-code evidence: `desktop/src/workspace_layout.rs:355 [UTF8-LF sha256=e7682600d1c90ee1805b3f70838d934bfd23de795ae50fa69599c034c88906db; anchor=fn reconnect_and_reboot_restore_splits_active_tabs_and_hidden_choices]`. - Test-code evidence: `desktop/src/workspace_layout.rs:457 [UTF8-LF sha256=e7682600d1c90ee1805b3f70838d934bfd23de795ae50fa69599c034c88906db; anchor=fn focusing_with_hidden_events_and_streaming_retains_custom_slots_after_restart()]`. - Test-code evidence: `desktop/src/workspace_layout.rs:514 [UTF8-LF sha256=e7682600d1c90ee1805b3f70838d934bfd23de795ae50fa69599c034c88906db; anchor=fn view_only_collapse_and_tab_scroll_do_not_rebuild_hidden_splits()]`. - Test-code evidence: `desktop/src/workspace_layout.rs:535 [UTF8-LF sha256=e7682600d1c90ee1805b3f70838d934bfd23de795ae50fa69599c034c88906db; anchor=fn actual_split_and_tab_order_edits_still_persist()]`. -- Test-code evidence: `mobile/test/output_panel_test.dart:287 [UTF8-LF sha256=d40cd2b5189af50d1e862629cae0facd6169d7151fbccb0325564a368312b4b6; anchor=Single-mode table has no empty Scene and hidden cards keep their saved order]`. +- Test-code evidence: `mobile/test/output_panel_test.dart:502 [UTF8-LF sha256=b75b25f46b391a84c0400f08361c4c520e2fda547e95f8b9030078f7f281e317; anchor=Single-mode table has no empty Scene and hidden cards keep their saved order]`. - Remaining: Actual Android installation, background/process pressure, Wi-Fi/router loss and human layout/selection acceptance remain open. The newer prior APK was staged but not installed because no authorized ADB target was available; never attribute its source checks to the running phone. Complete generic workspace-management imports separately; source fixtures are not phone deployment or donor-wide parity. - Remaining: Issue #10/#3: runtime visual/scroll acceptance and remaining generic workspace dialog/action parity remain open. - Remaining: Installed native restart/reconnect/scroll and Android process-pressure/human acceptance remain separate from serialization fixtures. Native window geometry and full named workspace manager CRUD remain pending. @@ -3610,10 +3886,10 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `e06205e3e023e47e8a8c646e881044ce967e793628f7d52159fbbdf589a6f0d7`. -- Target: `desktop/src/native_workspace.rs:48 [UTF8-LF sha256=5003829240d453d4917fa4e07dc74474b999a8d4bf9adcf661b36334b550da60; anchor=pub struct Snapshot]`. +- Target: `desktop/src/native_workspace.rs:48 [UTF8-LF sha256=15688543f8999613f064f17e60d453f50d59a05c564e927379fb86d50bc4f0d8; anchor=pub struct Snapshot]`. - Retained: Completely reread actual test. Adapt fresh-session serde roundtrip and persisted layout/state assertions to the actual native board/application workspace store, using actual egui ScrollArea state and DockState. No media dependencies or per-test temporary-directory cleanup copied. - Omitted/replaced code: `Donor playback/media config fields`. Disposition: **not-applicable**. Reason: The requested controller is mpv-free; this fixture maps reusable persistence assertions, not media runtime restoration. -- Test-code evidence: `desktop/src/native_workspace.rs:499 [UTF8-LF sha256=5003829240d453d4917fa4e07dc74474b999a8d4bf9adcf661b36334b550da60; anchor=fn same_peer_reboot_restores_only_its_workspace_and_other_peers_cannot_inherit_it]`. +- Test-code evidence: `desktop/src/native_workspace.rs:515 [UTF8-LF sha256=15688543f8999613f064f17e60d453f50d59a05c564e927379fb86d50bc4f0d8; anchor=fn same_peer_reboot_restores_only_its_workspace_and_other_peers_cannot_inherit_it]`. - Remaining: Actual installed GUI restart/scroll acceptance and complete unrelated configuration field coverage remain open. ### Pealayer: `tests/i18n_surface_audit_test.rs` @@ -3654,9 +3930,9 @@ Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1c Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `ba2d7a35e2f887ec556b681cca24aaa6f263295208cbc80678bc248b61949845`. -- Target: `firmware/esp/src/WebUi.h:2197 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function createWorkspaceStorage]`. +- Target: `firmware/esp/src/WebUi.h:2457 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function createWorkspaceStorage]`. - Target: `assets/web/device-settings.js:93 [UTF8-LF sha256=28b490c94a63c0b55df4cac22405b02d9ef43725ee08d5e9e3cc418ae8df5492; anchor=function settingsObserve]`. -- Target: `assets/web/workspace-studio.js:9 [UTF8-LF sha256=bdc455a67af4c9803cd9ea76c725d800b8bdca2e98c831968b1b5a39840266e8; anchor=function studioRoute(]`. +- Target: `assets/web/workspace-studio.js:9 [UTF8-LF sha256=c5c4b999f9fb7ca6011b9193a1ded10ed293bba8b53d9c861ad1ea4c108a15f4; anchor=function studioRoute(]`. - Retained: Completely reread matching actual donor. Import/adapt readStoredJson/persistJson pending-map/timer/pagehide and per-surface scroll patterns into immutable board_id+application.id workspace storage. Selection/find/focus, presentation/panels, public drafts, settings navigation and scroll restore passively; equal writes skip, blocked storage retains in-memory state, and changed fresh settings keep their conflict baseline. - Retained: Reread App.tsx through EOF for actual surfaceFromLocation/setActiveTab/followLocation. Adapt the allowlisted fragment, same-path/query history update with equal-write skip, and hashchange/popstate listeners into the EXISTING Studio workspace owner. Explicit links wait for acknowledged panels rather than unhiding capabilities, and do not rebuild card/draft owners or issue commands. Generic browser-tab localStorage fallback was not duplicated; existing peer-scoped workspace persistence remains authoritative. - Omitted/replaced code: `Cached player/runtime state, media navigation and React/Ant Design surface ownership`. Disposition: **replaced**. Reason: Adapt generic persistence to embedded DOM ownership with a workspace-only allowlist; no playback engine, stale live authority, modes/cells, notifications, boot identities or pending command replay. @@ -3671,7 +3947,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Target: `assets/themes/appearance.js:4 [UTF8-LF sha256=62a7ebba994ad44f4e2d647bb97684aac9b563104f3f707b43573289e514ecdd; anchor=export function mergeAppearance]`. - Target: `tools/generate-web-appearance.mjs:33 [UTF8-LF sha256=e57a615bf5134b718d6df7b644e56faced0c0ef73e8662cf8cb06c05e23e59b4; anchor=replaceBlock('// BEGIN SHARED APPEARANCE']`. -- Target: `firmware/esp/src/WebUi.h:10 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=// BEGIN SHARED APPEARANCE]`. +- Target: `firmware/esp/src/WebUi.h:10 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=// BEGIN SHARED APPEARANCE]`. - Retained: Fully reread current source through EOF. The five previously adapted helpers remain in the target: mergeAppearance, resolvedAccent, resolvedAppearanceTheme, accentForeground and paletteName. Existing theme/palette/accent host resolution and luminance semantics remain; newer animation/density/font preference fields and font CSS helpers are explicitly pending, not claimed imported. - Retained: Generated into the shipped WebUi.h with the shared palette asset; --check rejects stale embedding. Browser preferences dialog validates theme/palette/accent, protects drafts, handles storage failure and cross-tab/System events without device writes or repetitive identical storage writes. - Omitted/replaced code: `ui_animation_mode, ui_control_density, ui_font_family, monospace_font_family merge fields; ControlDensity/controlDensityHeight 24/28/32/36 geometry; uiFontCss and monoFontCss`. Disposition: **pending**. Reason: New current donor behavior was fully read during mobile-network build preflight. Existing embedded target does not consume these client preferences and does not bundle PealayerUbuntu/PealayerHack; copying their font names would imply unavailable assets. Add actual shared settings/asset support and cross-client tests before claiming parity. @@ -3681,11 +3957,35 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Test-code evidence: `tests/web_appearance_test.mjs:66 [UTF8-LF sha256=f755365a55727e1f0ad3e3e57fdf9f93e990f16d4445840694e264b88710aa7f; anchor=dialog draft/cancel never changes]`. - Remaining: Implement native/Web/mobile shared preference transport and live authoritative host events, while keeping device parameters separate from client appearance; verify multi-client broadcast/readback. +### Pealayer: `web_ui/src/components/ColorDropdown.tsx` + +Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `49d37654faab5033df04954456d0fe980571ccbc66478caaab7007dae1b8c151`. + +- Target: `mobile/lib/color_picker.dart:50 [UTF8-LF sha256=8d1b4206d9b99bf4067df432e0147fdfc7fe67f60c6dab83ceb04478de2560c2; anchor=class _ColorPicker]`. +- Retained: Read the full donor. Retained a single focused picker, bounded scrollable content, named presets and keyboard-focusable controls, with session recents shared across callers. Flutter dialog owns Cancel/Apply and returns a color rather than inventing another state or transport owner. +- Omitted/replaced code: `Ant Design popup positioning and DOM refs`. Disposition: **replaced**. Reason: Flutter dialog focus and bounded scrolling provide the corresponding modal behavior without DOM-dependent code. +- Omitted/replaced code: `Donor directional-arrow preset roving navigation`. Disposition: **pending**. Reason: Flutter retains standard focus traversal; exact directional preset navigation has not been ported or verified. +- Test-code evidence: `mobile/test/appearance_live_test.dart:26 [UTF8-LF sha256=77e70b48b5faf8f8ed73ccb5711b0c0ae12889e11d09870780d47a1d1c6a6b13; anchor=testWidgets]`. +- Remaining: Issue #26: verify desktop keyboard and installed tablet/phone picker ergonomics; no claim of complete donor parity. + +### Pealayer: `web_ui/src/components/ColorPicker.tsx` + +Verdict: **scoped-improvement**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `024fdabb482a714c38a91d0dde5d0be11486b20859a019f4fbc2d3dd34295022`. + +- Target: `mobile/lib/color_picker.dart:33 [UTF8-LF sha256=8d1b4206d9b99bf4067df432e0147fdfc7fe67f60c6dab83ceb04478de2560c2; anchor=Future showLedColorPicker]`. +- Retained: Read the complete actual donor before implementation. Adapted preset selection, uppercase RGB representation and twelve deduplicated session recent colors into the shared Flutter picker used by semantic state appearance, temporary test color and game group overrides. An explicit Apply commits the local picker choice; Cancel makes no command or recent-color write. +- Retained: Added sixteen named LED swatches, HSV hue/saturation/brightness controls, validated optional hex entry, fixed circular hit targets and semantic labels. The picker itself owns no connection or persistence; existing peer-bound command/readback and game Save paths remain authoritative. +- Omitted/replaced code: `react-color ChromePicker and Ant Design runtime`. Disposition: **replaced**. Reason: Flutter uses native Material controls and HSVColor; embedding a second JavaScript UI runtime adds no useful color semantics. +- Omitted/replaced code: `Media-effect onChangeComplete callback`. Disposition: **replaced**. Reason: An explicit local Apply hands the value to the existing device-owner command path, avoiding accidental board changes while dragging a color slider. +- Test-code evidence: `mobile/test/appearance_live_test.dart:26 [UTF8-LF sha256=77e70b48b5faf8f8ed73ccb5711b0c0ae12889e11d09870780d47a1d1c6a6b13; anchor=testWidgets]`. +- Scoped advantage: Named LED presets and accessible HSV channel controls replace hex-only editing while explicit Apply avoids unintended hardware commands during dragging. +- Remaining: Issue #26: candidate source/widget verification is not installed Android touch or physical color acceptance. + ### Pealayer: `web_ui/src/components/ControlBar.tsx` Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `cbd6ebc09cbf23e4958c4d1979974d3e856f2096bcc2dcfd1556c5ef4c45cafd`. -- Target: `web/modules/components.js:96 [UTF8-LF sha256=84a86483502833830a8b60c2eb4f1c47945807252e92bdaa3de0b7f7dc0c8151; anchor=const actionIcons=]`. +- Target: `web/modules/components.js:98 [UTF8-LF sha256=200e38d3a70594f6f2463b03bc74df53cc10e17e8d2264c7851c200d9beff7d4; anchor=const actionIcons=]`. - Target: `assets/web/application-utilities.js:47 [UTF8-LF sha256=20744bfcfc0968df0205a46aeb3bdd513b205f0f91796b168dd99dcb16fdbbf1; anchor=function refreshLabels(]`. - Retained: The Web owner fully read actual C:/Users/David/AppData/Local/Programs/Pealayer/source/Pealayer/web_ui/src/components/ControlBar.tsx. Adapt its explicitly mapped contextual action/icon identities and separate localized captions to the existing controller actionIcons/controlIcons and semantic caption refresh. No transport, ACK or capability authority is replaced by the presentation pass; existing handlers and disabled state remain attached to their original controls. - Omitted/replaced code: `Media transport, track selectors, playback volume and timeline-specific actions`. Disposition: **not-applicable**. Reason: There is no corresponding media domain in the controller; generic control identity is retained independently. @@ -3699,8 +3999,8 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `ed527ea04c4dc8e9f51e755c0dc92b8cd9a71a414b2fbcfbaba96e97cd7916a7`. -- Target: `web/modules/components.js:97 [UTF8-LF sha256=84a86483502833830a8b60c2eb4f1c47945807252e92bdaa3de0b7f7dc0c8151; anchor=const controlIcons=]`. -- Target: `assets/web/localization.js:43 [UTF8-LF sha256=2553d87375244b6de1e8ed82e63978237d990c5736aa848e66f53a7ac7849f83; anchor=function renderUiText(]`. +- Target: `web/modules/components.js:99 [UTF8-LF sha256=200e38d3a70594f6f2463b03bc74df53cc10e17e8d2264c7851c200d9beff7d4; anchor=const controlIcons=]`. +- Target: `assets/web/localization.js:43 [UTF8-LF sha256=fc7a39cae41e490fba858a8c7129abf0a7064811c486ddf9ba1b115b79e7c0fb; anchor=function renderUiText(]`. - Retained: Read the actual complete canonical Pealayer field-label component. Its explicit field-to-icon dictionary, decorative aria-hidden glyph and separate translated copy inform the controller's explicit action/control icon dictionaries and stable label hydration. Unknown peer-provided text is not classified by translated spelling; SVG remains independent of the localizer's owned label. This is a focused icon/caption adaptation, not a claim that all designer fields exist in Pakar. - Omitted/replaced code: `Media/timeline-specific field dictionary entries`. Disposition: **not-applicable**. Reason: Only actual controller actions receive mappings; unrelated media designer labels are not fabricated. - Omitted/replaced code: `Complete generic field-label coverage across all controller settings`. Disposition: **pending**. Reason: This pass binds current action/control icon and caption consumers. Full settings-field inventory and installed translated field layout remain additional work. @@ -3712,10 +4012,10 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `8c90f7a85286efada6d5a1e24212a03dd1ae14e51bea1767f8d195a538ec8f80`. -- Target: `assets/web/feature-workspaces.js:344 [UTF8-LF sha256=e3ee781bcd00a2f84a06f893fa224244b8d555b7b9d956edc5cfe30588aa17c0; anchor=async function reloadCorrections(]`. -- Target: `web/modules/components.js:133 [UTF8-LF sha256=84a86483502833830a8b60c2eb4f1c47945807252e92bdaa3de0b7f7dc0c8151; anchor=function dialog(]`. +- Target: `assets/web/feature-workspaces.js:366 [UTF8-LF sha256=93f24fd4008ef69eb247dd02d084a531a04b1dffaa86d6b651df43b5b6c3f33a; anchor=async function reloadCorrections(]`. +- Target: `web/modules/components.js:135 [UTF8-LF sha256=200e38d3a70594f6f2463b03bc74df53cc10e17e8d2264c7851c200d9beff7d4; anchor=function dialog(]`. - Target: `assets/web/application-utilities.js:116 [UTF8-LF sha256=20744bfcfc0968df0205a46aeb3bdd513b205f0f91796b168dd99dcb16fdbbf1; anchor=initialFocus:search]`. -- Target: `assets/web/pixel-groups.js:58 [UTF8-LF sha256=6541ad578203cbeeb5884e3de0425c48b8a8964214a42fcc68f95f50bcf0020f; anchor=async function reloadGroups(]`. +- Target: `assets/web/pixel-groups.js:58 [UTF8-LF sha256=24abe18fbf1f11fe248f2f1ac5fe6b7149014be1290fae0f8b3bef59c256e617; anchor=async function reloadGroups(]`. - Retained: Read the full donor through EOF and verified live bytes match the inventory. Adapt the retained local-draft modal, explicit localized cancel/commit actions, separate semantic icons and destroy-on-dismiss lifecycle through Pakar's existing WebUI.dialog; no second UI runtime or connection owner is introduced. - Retained: The canonical Web correction Reload confirmation also follows the actual Flutter PixelCorrectionsPage.reload guard: Cancel/close keeps edits, approval reads only the original peer, duplicate prompts are suppressed, and disconnect/capability loss/peer change/disposal dismiss the pending decision. Missing dialog support fails closed. This scoped comparison does not certify every EffectGroupDialog feature. - Retained: Reread the full donor for its explicit localized actions, auto-focused editor and destroy-on-dismiss behavior. Existing HTML dialog now links its description for assistive technology, accepts localized close labels and deliberate initial focus, focuses Cancel for correction discard and search for the command palette, serializes asynchronous footer actions, restores disabled states after failures, and retires late callbacks after dismissal. No duplicate modal runtime, connection owner or board command was added. @@ -3723,21 +4023,21 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `React/Ant Design Modal runtime`. Disposition: **replaced**. Reason: Reuse Pakar's existing native HTML dialog with focus restoration and shared branding rather than loading a duplicate framework into the onboard build. - Omitted/replaced code: `Effect-group name/original_name save and media-group mutation`. Disposition: **not-applicable**. Reason: This correction reload reads saved board configuration; it must not mutate media effect groups. - Omitted/replaced code: `EffectIconPicker searchable icon presets and live group-name/glyph preview`. Disposition: **pending**. Reason: Generic icon-picker behavior remains useful for configurable app elements. This narrow reload dialog has no editable icon/name and does not imply that picker has been imported or its dependencies reviewed. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:116 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Only one confirmation may own the editor]`. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:117 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Only one confirmation may own the editor]`. - Test-code evidence: `tests/web_dialog_test.mjs:53 [UTF8-LF sha256=63745a228e1e9748f7d8eb89c7f1a3eaa4f4e1e5d34b6fe5f6805c03e668552d; anchor=Only one asynchronous action owns the dialog]`. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:371 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Only one group confirmation owns the draft]`. -- Remaining: Rendered keyboard/focus/Escape, phone/tablet/desktop and Persian RTL acceptance remain open. Browser input was stalled in this checkpoint; passing DOM mocks are not rendered acceptance. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:389 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Only one group confirmation owns the draft]`. +- Remaining: 2026-10-27 desktop browser verification reproduced approval losing focus while Reload was disabled. Both correction and group confirmations now retain the existing async dialog owner through the read; delayed-read tests verify the trigger is enabled before close and stale approval stays inert. Actual correction Reload restores focus, Escape preserves drafts, and Cancel receives initial focus. Expanded map/filter/cursor numbering and persisted filter were also inspected. Phone/tablet/Persian RTL, real board reads and group-dialog rendered acceptance remain open. ### Pealayer: `web_ui/src/components/HeaderBar.tsx` Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `ad53ee284d969054f69b65e8447a313498b5928eae0aed2564e95f8c71e70d04`. -- Target: `tools/generate-branding.mjs:3 [UTF8-LF sha256=c4d0ab1f747b9b73d05fef2171e504fc3612e1a4b3b8bb35d929d885500e43a9; anchor=Pakar embeds it for the standalone ESP surface]`. +- Target: `tools/generate-branding.mjs:3 [UTF8-LF sha256=816544b2525d3c9210a586b8c2c5d59ed6c51fab8ca4bda27fbf2edbdd451367; anchor=Pakar embeds it for the standalone ESP surface]`. - Target: `mobile/lib/header_controls.dart:17 [UTF8-LF sha256=fdbae404068b75de261d1add7d5768e1b6844272ddfe4edb1b92b338f6cf4f87; anchor=class HeaderLayout]`. -- Target: `mobile/lib/main.dart:764 [UTF8-LF sha256=fee594559bfd86b5bfca6752fd68d9e5efb3fd3625dc7c9b1c2f1f8da70a2e84; anchor=Future _openConnection()]`. -- Target: `firmware/esp/src/WebUi.h:392 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=]`. +- Target: `mobile/lib/main.dart:767 [UTF8-LF sha256=814e5944b0f27d21f2005ff4bfa8e391dc5721435e25f9c40ccf2c934090a7db; anchor=Future _openConnection()]`. +- Target: `firmware/esp/src/WebUi.h:399 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=]`. - Target: `docs/BRANDING-PARITY.md:60 [UTF8-LF sha256=0bfa8122dc9d6034551304f2fed472ddebcff170e14a23f63011177aab75d2d3; anchor=## Explicit unfinished useful code and reasons]`. -- Target: `firmware/esp/src/WebUi.h:2653 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function renderApplicationIdentity]`. +- Target: `firmware/esp/src/WebUi.h:3052 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function renderApplicationIdentity]`. - Target: `assets/web/device-settings.js:120 [UTF8-LF sha256=28b490c94a63c0b55df4cac22405b02d9ef43725ee08d5e9e3cc418ae8df5492; anchor=function settingsCanClose()]`. - Target: `docs/INTERFACE-CONTRACT.md:8 [UTF8-LF sha256=37a0aec859267e0fd62b546ffc044eaa02ae04d2f9a298c5d94a5c24c803cb1b; anchor=## One catalog, distinct transport and platform adapters]`. - Retained: Actual resolved-artwork img/alt binding adapted to standalone header/login, replacing diamond and font fallback. @@ -3749,7 +4049,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Omitted/replaced code: `capability-aware Dropdown install/update/share/fullscreen/notifications/haptics/audio/wake-lock cluster`. Disposition: **pending**. Reason: Requires actual WebPlatform integration and ownership/permission tests; not excluded media business. - Omitted/replaced code: `Current keyboard-activatable connection-pill, collapsible action overflow parity and capability-aware web-feature preference switches`. Disposition: **pending**. Reason: Pakar must adapt actual endpoint lifecycle, capability/permission detection and keyboard semantics rather than presenting nonfunctional shortcuts. Shared static artwork and device-settings drafts do not provide these generic browser/header behaviors. - Test-code evidence: `mobile/test/header_controls_test.dart:100 [UTF8-LF sha256=db7a6c884949c426561569544a4a17e1be668e6f599a920ffe56f0f3a65539d9; anchor=Header customization saves and restores without changing board state]`. -- Test-code evidence: `mobile/test/status_bar_layout_test.dart:51 [UTF8-LF sha256=11ff7bcfe66ee8bcf0eb7037de48ae49db0275cc2f5e9dd0d40b05e9045bdf22; anchor=Entire connection status icon and text open connection, errors stay inspectable]`. +- Test-code evidence: `mobile/test/status_bar_layout_test.dart:93 [UTF8-LF sha256=52cb9e5aef5b21cb81477077b515c21c5108b075168eb410ce5617ba9614e447; anchor=Entire connection status icon and text open connection, errors stay inspectable]`. - Test-code evidence: `tests/branding_test.mjs:12 [UTF8-LF sha256=c39528379a8b6e97561e0e0deb5e435a182f75bce2b385780c7b9ee853455fbb; anchor=actual Pealayer icon_data_from_bytes import]`. - Test-code evidence: `tests/web_identity_test.mjs:40 [UTF8-LF sha256=c4824a7923009f8698e0cde21c1d693609d1af98f55c47dbc20a140293a576be; anchor=Actual Web application identity]`. - Test-code evidence: `tests/interface_settings_test.mjs:33 [UTF8-LF sha256=102d6c411b732bad8106dd09358da3df3989d1140aa77c87a884c8072c66c6f3; anchor=Failed saves preserve the draft]`. @@ -3759,12 +4059,12 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `6f1e99cd5ae8ef0bae30d43a86f22a22371c6543498c5d8938e486cff151d218`. -- Target: `mobile/lib/pixel_corrections_editor.dart:111 [UTF8-LF sha256=8f2332519d2fbf57767e5999201172d1ffcd1be9010faf4b1bd2289093d5f80c; anchor=class PixelCorrectionNumberControl]`. -- Target: `assets/web/pixel-groups.js:44 [UTF8-LF sha256=6541ad578203cbeeb5884e3de0425c48b8a8964214a42fcc68f95f50bcf0020f; anchor=function create(options)]`. -- Target: `assets/web/feature-workspaces.js:263 [UTF8-LF sha256=e3ee781bcd00a2f84a06f893fa224244b8d555b7b9d956edc5cfe30588aa17c0; anchor=function stepped(]`. -- Target: `assets/web/command-transport.js:23 [UTF8-LF sha256=59fbe53d3d9805dc65c2a22100455ba4f17c18f2bae72e080f230108f5cc96e4; anchor=if(path==='/api/led/diagnostic')]`. +- Target: `mobile/lib/pixel_corrections_editor.dart:115 [UTF8-LF sha256=7b307453c53c084f22bb5e3845b43ba56e34a83633447b36041c4b1facad64c5; anchor=class PixelCorrectionNumberControl]`. +- Target: `assets/web/pixel-groups.js:44 [UTF8-LF sha256=24abe18fbf1f11fe248f2f1ac5fe6b7149014be1290fae0f8b3bef59c256e617; anchor=function create(options)]`. +- Target: `assets/web/feature-workspaces.js:282 [UTF8-LF sha256=93f24fd4008ef69eb247dd02d084a531a04b1dffaa86d6b651df43b5b6c3f33a; anchor=function stepped(]`. +- Target: `assets/web/command-transport.js:62 [UTF8-LF sha256=c7b5e835f07cd2fc7900808a78b4be1b0753667eb34d6b4b2664a7c1b5fe96a8; anchor=if(path==='/api/led/diagnostic')]`. - Target: `assets/web/studio.css:122 [UTF8-LF sha256=4d87d4cc97b3a53b2144912cd0c123ebc21b9b7677bdb02e6901a6c695e36e22; anchor=.studio .mapping-pixel>.mapping-dot]`. -- Target: `assets/web/controller-copy.json:301 [UTF8-LF sha256=0a88e85d9cd5c30c5bad9f91505a54a5740417c18f6bc17103c75ed374a22ed6; anchor=Rule preview]`. +- Target: `assets/web/controller-copy.json:300 [UTF8-LF sha256=441c7b489b0e5b683da61cd667f9251d3542e0be57b1abb2e0780497455a0b22; anchor=Rule preview]`. - Retained: Correction-editor owner read the complete donor. Adapted bounded +/- stepping, disabled endpoint controls, named numeric fields and typed-value validation into the existing Flutter theme; no additional styling framework or duplicate settings owner. - Retained: Per-group pixel sizing additionally adapts the bounded +/- stepper, local numeric draft and explicit commit to the existing DOM Web controls. Physical address ranges preview cumulative counts without writing the board; save uses revisioned ownership and exact readback. The complete donor was reread for this addition. - Retained: Reread the complete donor for Web correction per-row pixel and signed-offset steppers. Adapt bounded one-pixel +/- and disabled endpoints, input labels and local-only draft edits using existing shared icon buttons, with fixed square geometry. Approximate rule labels reuse the actual Flutter pixelCorrectionSources/pixelCorrectionAddress projection, respecting preceding enabled rules, current physical spans and logical permutation; stale/missing layouts fail closed. @@ -3778,11 +4078,11 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Retained: Verified unchanged Pealayer NumericValueControl source and reread actual Flutter operationOption/PixelCorrectionDiagram function slices. Reuse operationOption's clamped six-slot window and prefix-through-current-rule projection in optional collapsed Web row disclosures; actual enabled/global flags remain authoritative for this local projection. Main map and compact row windows now share mappingPixel/paintDiagram and existing observed RGB validation, not duplicated engines. Descriptions include black/remove/positive-negative shift semantics with Persian copy. Invalid current/prior numeric drafts clear dependent previews, late retired disclosure callbacks are fenced, live RGB retains nodes, reorder carries expanded presentation state and no previewOpen field enters canonical saves. Browser expanded/RTL/touch and rich operation-menu parity remain open. - Omitted/replaced code: `React/CSS execution and media-value callers`. Disposition: **replaced**. Reason: Flutter or Pakar's canonical DOM owns rendering. The bounded-control interaction is adapted rather than embedding a second framework; pixel addresses and signed offsets replace media-value domains. - Omitted/replaced code: `Custom fine/coarse modifier step configuration and clipboard context menu`. Disposition: **pending**. Reason: The pixel-count editor uses bounded integer one-pixel steps and explicit reset. Donor custom modifier steps/clipboard menu were read but are not yet imported; this is a scoped sizing feature, not complete NumericValueControl parity. -- Test-code evidence: `mobile/test/pixel_corrections_editor_test.dart:143 [UTF8-LF sha256=b54dabf7b3b719fa4df58191a057d9e42058ae335b3a75037b6e8bab7f766370; anchor=void main()]`. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:388 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Retired group row callbacks cannot mutate replacement draft]`. -- Test-code evidence: `tests/web_command_transport_test.mjs:45 [UTF8-LF sha256=bcbf0f527c9ec50ea1194fecbf07341cf6eb9f7b96b5d962ea28fa426ab398e0; anchor=captured.composite,'overlay']`. +- Test-code evidence: `mobile/test/pixel_corrections_editor_test.dart:145 [UTF8-LF sha256=ab953eb195c5f8ba3c086666c2f5a7444bccd7d2fb03433c2434d412a8aedeb4; anchor=void main()]`. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:406 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Retired group row callbacks cannot mutate replacement draft]`. +- Test-code evidence: `tests/web_command_transport_test.mjs:59 [UTF8-LF sha256=14e3581c9d9289f2059c9f23f4de5bcd4ff803e813f7c9db062ca0408e4b671f; anchor=captured.composite,'overlay']`. - Test-code evidence: `tests/web_control_presentation_test.mjs:96 [UTF8-LF sha256=f563ad7b87d95fdb08b84737436774bfc4d21a709678007287d25706f05c2272; anchor=Pixel circles retain fixed square geometry]`. -- Test-code evidence: `tests/web_feature_workspaces_test.mjs:40 [UTF8-LF sha256=f51019992a9162e84251167d39fe4f1706446a4816c840a9daf8e146d371a9f6; anchor=Rule window includes its prefix, not later rules]`. +- Test-code evidence: `tests/web_feature_workspaces_test.mjs:41 [UTF8-LF sha256=0ee5cabce984e6f4e39fcdbe057e65897559a1f67dc1a35e010dab929ed691ca; anchor=Rule window includes its prefix, not later rules]`. - Remaining: Rust correction editor does not yet expose these new per-row/preview controls; no cross-client parity claimed. ### Pealayer: `web_ui/src/components/PlaybackButton.tsx` @@ -3790,7 +4090,7 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `10b2c97e2b85d1f5390790992b53a8250dc49847ce0c4b1679f5928e30b9fe5a`. - Target: `assets/web/application-utilities.js:37 [UTF8-LF sha256=20744bfcfc0968df0205a46aeb3bdd513b205f0f91796b168dd99dcb16fdbbf1; anchor=function setControlCaption(]`. -- Target: `web/index.html:2830 [UTF8-LF sha256=64ff59a7b22d89162c426e99ee8a6f0fd9a947c58143a8d76d462b5ebca2f601; anchor=function renderAssignmentTools(]`. +- Target: `web/index.html:3248 [UTF8-LF sha256=783ffe5a5dbe4b96c898cb896437247749f29bb8c8d2fecbb9659d5fae243b27; anchor=function renderAssignmentTools(]`. - Retained: The Web owner fully read the actual canonical Pealayer component. Preserve its authoritative-snapshot action meaning, disabled unknown/unavailable state and matching icon/label/accessibility rather than optimistic click-state toggling. Existing cell ACK/capability gates remain authoritative; state-caption refresh uses the existing semantic state model and stable label slot. Selection by lost/won identity remains correct even when a user changes the configured RGB color. - Retained: Only canonical Pealayer web_ui/src/styles.css lines 77-102 and 521-552 were inspected as styling dependencies, SHA256 2d08f0129a28e29b55a137c708b9296d5282bd0aaffb6c2fdba6b9d5cf16462d. Fixed borders, optical glyph alignment and restrained tonal states were adapted; the whole stylesheet is not certified read. - Omitted/replaced code: `Play/Pause media command and React/Ant component runtime`. Disposition: **not-applicable**. Reason: The board application has no media player; only authoritative state and visual/action identity are adapted into its existing DOM model. @@ -3803,12 +4103,12 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `30a63cc1db61a46346c2d4c30ce011dc74081a8a6da2e29853e56d39811a0de1`. -- Target: `mobile/lib/device_settings_page.dart:18 [UTF8-LF sha256=1d618c036930215ab12bc3a827625eef5819a3969a7663615115a65ba8519c27; anchor=class DeviceSettingsPage]`. +- Target: `mobile/lib/device_settings_page.dart:18 [UTF8-LF sha256=b187bcc1ed640dc8b7052ce03c4f36a840289396f8908dbc77b967190eb9e9ee; anchor=class DeviceSettingsPage]`. - Target: `mobile/lib/device_settings.dart:413 [UTF8-LF sha256=0c10e5c712bae2b4fe2f706326115ee703f07e426a64a50728c09575efb71e9a; anchor=class DeviceSettingsSession]`. - Target: `mobile/lib/led_backend_control.dart:9 [UTF8-LF sha256=4769c24f714e241c5acb41987f5cbefef35681a86fbcda40c92ffb7db934c33a; anchor=class LedBackendControl]`. - Target: `mobile/lib/led_backend.dart:5 [UTF8-LF sha256=ccea33fd885d514027e622061bf55daf4d0c3564f977529b9956c6df42d57491; anchor=class LedBackendState]`. -- Target: `web/index.html:2360 [UTF8-LF sha256=64ff59a7b22d89162c426e99ee8a6f0fd9a947c58143a8d76d462b5ebca2f601; anchor=function renderLedBackendControl(anchor){]`. -- Target: `web/index.html:2331 [UTF8-LF sha256=64ff59a7b22d89162c426e99ee8a6f0fd9a947c58143a8d76d462b5ebca2f601; anchor=function waitForLedBackend(]`. +- Target: `web/index.html:2623 [UTF8-LF sha256=783ffe5a5dbe4b96c898cb896437247749f29bb8c8d2fecbb9659d5fae243b27; anchor=function renderLedBackendControl(anchor){]`. +- Target: `web/index.html:2594 [UTF8-LF sha256=783ffe5a5dbe4b96c898cb896437247749f29bb8c8d2fecbb9659d5fae243b27; anchor=function waitForLedBackend(]`. - Retained: Actual contract-driven sections/selected-section navigation, grouped labeled typed controls, contextual options, retained draft on section changes, descriptions, load/save status and reload adapted to Flutter DeviceSettingsPage using the existing shared Pakar catalog. Tablet uses a searchable navigation rail; phone uses wrapping section chips. Actual Phosphor repertoire supplies verified icon codepoints. - Retained: Actual valueAtPath/updatePaths structured settings ownership adapted to separate public drafts and secret actions. DeviceSettingsSession uses the fully read canonical Pakar native/Web validation, observe/conflict and positive acknowledgment/exact public readback workflow rather than generic optimistic UI success. In-memory secrets never enter saved client preferences; one POST body per explicit operation, no mutation replay. - Retained: Runtime LED transmitter checkpoint reread all563 donor lines and verified SHA30a63cc1. Adapt actual contract-derived select options, field-scoped saving state and external-observation refresh to Flutter LedBackendControl/LedBackendState and the single canonical Web selector. Only the intersection of advertised compiled choices is offered; selection reflects actual driver telemetry and renderer mode stays independent. Replace updatePaths optimistic save with one correlated mutation and same-peer requested/active/readiness/error/persistence observation. WebSocket events settle the result; configured bounded HTTP fallback reads never replay the mutation. This is a scoped mechanism adaptation, not a direct TSX component import or full PreferencesTab parity. @@ -3832,11 +4132,13 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `df5b59007e0b726c815bed0eafcce87e4f5a6e4a59e8c79a1414f2cc7be89316`. +- Target: `assets/web/user-files.js:35 [UTF8-LF sha256=8b25d6925e5a604b95769d844c1c1d7d06ae9e49a33a9e2307bae5c75d77ec5f; anchor=function create(o)]`. - Target: `mobile/lib/documents.dart:19 [UTF8-LF sha256=9b447b248f21e855c2481e28c6c31e66969af5ab837fd32e4d57eb76164b9ff5; anchor=class AndroidDocuments]`. -- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:50 [UTF8-LF sha256=8988cab4eac7a2f1a831cb0e0ab1179fd7e194030feafaf3ccaef57d3f13fdfe; anchor=Intent.ACTION_OPEN_DOCUMENT]`. +- Target: `mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt:52 [UTF8-LF sha256=262271e184d4287fa3e2a6dc8cac9cae7f5cb0cdf7e2b143ecfaa4288bdfd0cf; anchor=Intent.ACTION_OPEN_DOCUMENT]`. - Target: `mobile/lib/firmware_page.dart:17 [UTF8-LF sha256=c33c2df6dfc69b5f3b69f1a4f77b8feb5497e90ed3458a5e84f1bb2cc3c7d319; anchor=class FirmwarePage]`. - Target: `mobile/lib/firmware.dart:178 [UTF8-LF sha256=190ddce7b1bbf298a74464ea8202287ead801bfe8725ab4d0af39993d5a6c30e; anchor=class FirmwareController]`. -- Target: `mobile/lib/api.dart:1430 [UTF8-LF sha256=ad0237be070fbf17b698c929036072704fd91ee898a7cbe3940fc96618b0cb3a; anchor=Future maintenanceBytes]`. +- Target: `mobile/lib/api.dart:1563 [UTF8-LF sha256=bbb407cc65ccd54e10d7a21328d07831ce5fa3eff70eebe99a6a10b3815257e8; anchor=Future maintenanceBytes]`. +- Retained: Issue #28: reread the complete picker and preferenceFiles helper. Adapt bounded list/filter, explicit file actions and stale asynchronous reply rejection into the canonical Web Files panel. Board-managed names replace arbitrary host paths; uploads are staged and verified, and file replacement is explicitly confirmed. Original bytes and modification timestamps are kept together; PAX export preserves timestamps without claiming browser filesystem permissions. - Retained: Fully read actual typed single-file selection, disabled/unselected/loading state, extension filtering, cancellation, stale-result protection and explicit acceptance. Adapted into Flutter AndroidDocuments + native SAF; firmware imports are validated and separately confirmed before any upload. Parent navigation is locked during maintenance confirmation/picking and writes; selected bytes never automatically program either board. - Retained: Canonical Pakar desktop maintenance and actual firmware routes were read, not fabricated mobile endpoints. FirmwareController pins remote job/boot/hash/targets and verifies a fresh identity before apply/cancel; single-dispatch bounded HTTP refuses redirects/replay. FirmwarePage gates each ISP/OTA domain on individual live feature ACK, distinguishes stage/apply/completion, and exposes real full flash/EEPROM exports. - Omitted/replaced code: `React/Ant Design modal/table row handlers, server /api/fs/browse, Directory.path/parent_path and AbortController network browse`. Disposition: **replaced**. Reason: Android SAF owns user-granted document navigation, search, single selection and cancellation. ESP has no arbitrary host filesystem browse contract; inventing that endpoint or accepting Windows paths would be incorrect. The native picker reads bounded bytes on its executor; captured board ownership is checked after picker/HTTP awaits. Only a one-use user-created destination token can be exported, with no broad storage permissions. @@ -3852,9 +4154,9 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `331d2545379e36ea717fcf2876508315437bc5abe49923170f50a4f1bb1b47fc`. -- Target: `assets/web/localization.js:19 [UTF8-LF sha256=2553d87375244b6de1e8ed82e63978237d990c5736aa848e66f53a7ac7849f83; anchor=function uiTr(text,values)]`. -- Target: `tools/generate-font-assets.mjs:62 [UTF8-LF sha256=25206183ce0d33c9be56f11bd39d9d802a18581543263714962b053070c78466; anchor=const dictionary={}]`. -- Target: `firmware/esp/src/WebUi.h:788 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=// BEGIN SHARED LOCALIZATION]`. +- Target: `assets/web/localization.js:19 [UTF8-LF sha256=fc7a39cae41e490fba858a8c7129abf0a7064811c486ddf9ba1b115b79e7c0fb; anchor=function uiTr(text,values)]`. +- Target: `tools/generate-font-assets.mjs:62 [UTF8-LF sha256=fdf6b75dac5afebad8598abbe408cd44bbf09cb306d4db91ee515add5ebb1412; anchor=const dictionary={}]`. +- Target: `firmware/esp/src/WebUi.h:795 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=// BEGIN SHARED LOCALIZATION]`. - Retained: Fully reread all 630 current donor lines through EOF. Actual fa-dictionary fallback semantics remain reused for application copy, rather than translating arbitrary runtime data. Applicable native dictionary subset generates into the canonical Web module and actual firmware HTML; the new generic hardware/status/font/control vocabulary is useful pending consumer work, not an automatic dictionary import. - Retained: Added system/explicit language and Auto/LTR/RTL preferences, owned-template text/placeholder/accessibility snapshots, per-browser low-write persistence, shared font route and immutable peer-name boundary. - Retained: Persian Web regression slice: controller captions now include preferences, network/cloud/access/firmware and cell dialogs; capability mode labels participate in dictionary generation. Live telemetry and dynamic accessibility labels refresh in the selected language, real addresses release stale caption ownership, option values remain protocol-stable, and Persian text omits Latin letter spacing. Read-only actual HTML browser inspection verified mobile-width preferences/settings and reversible persistent locale switching; this is not live board acceptance. @@ -3883,23 +4185,23 @@ Verdict: **equivalent-behavior**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1c Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `1910311a3acba2f1d22d9d4fb57d43e08bcf6ee91f3ea567e381ae90e8a487cd`. - Target: `web/modules/table-layout.js:7 [UTF8-LF sha256=30162a2894f72d2467b34fc2cb3e682e09b4da0a1b252ccf6579a25273b53542; anchor=function zoomAt]`. -- Target: `mobile/lib/table_viewport.dart:88 [UTF8-LF sha256=9086d22cf19e977ee51a9907a987b6493335a924865cd62512b9bc3863b13627; anchor=void zoomAt]`. -- Target: `desktop/src/ui/shell.rs:407 [UTF8-LF sha256=9794439f13e7a573b97ede3eb62c624a65fa0b353640e77e4a25c5547734f679; anchor=egui::Scene::new()]`. +- Target: `mobile/lib/table_viewport.dart:158 [UTF8-LF sha256=d7fe024ea911c1610e36e88a8a1fc412e0b95c615584ebb30322d5870ee032a7; anchor=void zoomAt]`. +- Target: `desktop/src/ui/shell.rs:409 [UTF8-LF sha256=015877d9b262b596d4e27fdddcbd03a42c8c1e1cbe2b15f46632a88bce7ccce8; anchor=egui::Scene::new()]`. - Retained: Read complete donor. Adapted exponential wheel zoom and pointer-anchor equation to two dimensions and fit-relative 0.5-8x scale. Flutter and canonical Web use this math; Rust uses the locked egui Scene implementation with matching anchor semantics and transformed hit testing. Fit follows the dock or client viewport; telemetry does not reset manual zoom. - Retained: Web presentation checkpoint reread the complete actual timelineWheel.ts dependency. The current table-layout attach changes only Fit/zoom button glyphs, translated captions and accessible names; zoomAt mathematics, gesture ownership and view persistence are unchanged. The target reference is rebound for that reviewed presentation change without claiming new zoom behavior or physical gesture acceptance. - Retained: Tablet refinement reread the complete donor and retained its anchored exponential zoom. Flutter now applies continuous scale ratios against current view state, including multiple updates before a render, hides explicit controls only for Android/iOS tablets at least 600 logical pixels on the shortest side, and gives the zoomed canvas touch/wheel pan ownership without stealing fitted-page scrolling. Phone/desktop controls and the same user-owned controller remain. Focused gesture tests and isolated tablet rendering are source/fixture evidence, not installed touch acceptance. - Omitted/replaced code: `sequenceWheelNavigation time scale, duration and track gutter`. Disposition: **not-applicable**. Reason: The controller table is a fixed spatial cell canvas, not a media sequence. - Omitted/replaced code: `Configurable plain, Shift and Alt wheel policies and physical horizontal modifier precedence`. Disposition: **pending**. Reason: Flutter now routes plain wheel to canvas panning while zoomed and Shift-wheel swaps axes; fitted view preserves ordinary page scrolling. Shared configurable plain/Shift/Alt policies and full physical-horizontal modifier precedence across clients remain unfinished; native Scene also supports trackpad pan. - Test-code evidence: `tests/table_zoom_test.mjs:7 [UTF8-LF sha256=0d7b5629ce4967cdfad392d035e6bc8617876da1824326115c1604a0b1d66048; anchor=for(const factor]`. -- Test-code evidence: `mobile/test/table_viewport_test.dart:234 [UTF8-LF sha256=3913d6173749d919df894b334eaba5b62231c7d7a424a304ac9ff9f01bb777b2; anchor=Pinch zoom]`. +- Test-code evidence: `mobile/test/table_viewport_test.dart:244 [UTF8-LF sha256=a68444f3d21c88ba16651920f6c22cb496cad082d609d69b59a66258a1ceb9b0; anchor=Pinch zoom]`. - Remaining: Issue #3: source tests do not certify deployed clients or real touch hardware. Preserve primary selection and order-edit ownership; verify native trackpad, tablet pinch and browser pointer behavior on installed clients. ### Pealayer: `web_ui/src/webPlatform.ts` Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. Original-byte SHA-256: `d6ee8217c05e96be2e20f9a40ab4c46249d57f58871c661a31c33e2310a92f0e`. -- Target: `tools/generate-branding.mjs:17 [UTF8-LF sha256=c4d0ab1f747b9b73d05fef2171e504fc3612e1a4b3b8bb35d929d885500e43a9; anchor='FAVICON':]`. -- Target: `firmware/esp/src/WebUi.h:142 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=]`. +- Target: `tools/generate-branding.mjs:18 [UTF8-LF sha256=816544b2525d3c9210a586b8c2c5d59ed6c51fab8ca4bda27fbf2edbdd451367; anchor='FAVICON':]`. +- Target: `firmware/esp/src/WebUi.h:142 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=]`. - Target: `docs/BRANDING-PARITY.md:60 [UTF8-LF sha256=0bfa8122dc9d6034551304f2fed472ddebcff170e14a23f63011177aab75d2d3; anchor=## Explicit unfinished useful code and reasons]`. - Retained: Same resolved artwork binds favicon/touch/header before login; ESP embeds data instead of requesting a protected host filesystem icon. - Retained: Current 383-line donor was completely reread. Existing favicon/header/touch artwork mapping remains valid; this is not browser-platform parity. @@ -3910,12 +4212,221 @@ Verdict: **parity-gap**. Revision: `5a6961ce6a477a4726b7baf8a84d12fde1cdf6ad`. O - Test-code evidence: `tests/branding_test.mjs:12 [UTF8-LF sha256=c39528379a8b6e97561e0e0deb5e435a182f75bce2b385780c7b9ee853455fbb; anchor=actual Pealayer icon_data_from_bytes import]`. - Remaining: Complete named generic omissions with preference/endpoint/runtime tests; source checks are not deployed acceptance. See BRANDING-PARITY.md. +### portable-shell: `_codeql_detected_source_root` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8`. + +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Inspected Git mode 120000 and target '.' at the pinned revision; accounted for without creating a recursive reference. +- Omitted/replaced code: `CodeQL root symlink to '.'`. Disposition: **not-applicable**. Reason: Environment-specific analysis marker is not shell code; recreating it would introduce self-referential source traversal. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `.gitignore` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `b7abe7efbbcb839e230ceb828522604b59f4faaf7b28b697aecb4c9df9f77fe0`. + +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Build/editor artifact exclusions retained in the exact reference snapshot; Pakar keeps its existing repository ignore policy. +- Omitted/replaced code: `Global wildcard ignores`. Disposition: **replaced**. Reason: Applying *.exe, *.a and IDE exclusions repo-wide would hide tracked recovery/test assets and existing project policies. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `CHANGELOG.md` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `7dd7e6e515634cc0c81ff1b1528ad5f0e5c29631417c427d50916a0de132224f`. + +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Read complete historical claims; retained verbatim as donor history, not imported as Pakar release certification. +- Omitted/replaced code: `Complete/security/performance claims`. Disposition: **replaced**. Reason: Source inspection shows placeholders and allocation in history/parser; an old claim of zero alerts cannot establish current safety. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `DOCUMENTATION.md` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `eafe52b90f65b56c32fedbc846463be5c4472994eb2584d3d260c64bbc2f4e35`. + +- Target: `firmware/esp/src/ShellProperties.h:12 [UTF8-LF sha256=7a6cb87cf78a769be09ac82a5d3d3b3912f485b36d98a6676b5320d8b54ed0fa; anchor=struct ShellProperty]`. +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Read all API/parameter/transport guidance; retained source documentation with corrected integration guidance. +- Omitted/replaced code: `Raw-pointer parameter binding and advertised editable TUI`. Disposition: **replaced**. Reason: Pakar validates durable settings through owner APIs and has a genuinely interactive staged TUI; no second unsafe parameter owner. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `examples/arduino_example.ino` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `fe59231f346c7f5d8e44b206c5f1862e4fd71f308e7473fe1723c54bdcc3bcce`. + +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Target: `firmware/esp/src/TelnetShell.h:139 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class LineEditor]`. +- Retained: Read complete sample callback/loop pattern. Existing loop-fed editor uses caller-owned output and bounded byte budgets. +- Omitted/replaced code: `Direct GPIO sample commands, while(!Serial), analogRead(0) loop`. Disposition: **replaced**. Reason: Arbitrary LED/pin access conflicts with board pin ownership; startup/loop work must not block UART/game scheduling. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `examples/linux_example.c` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `543e25c4d9c5a99ed9b7d225f33d984f05f5efd61e2bd43e68be75eec22aebf1`. + +- Target: `tools/device-shell.ps1:2 [UTF8-LF sha256=4e197e3502eb6ef40d2a3df7bdde97ed3c05140c97e06e3d07426232732a9c55; anchor=param(]`. +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Target: `tools/telnet-shell.mjs:49 [UTF8-LF sha256=85d7ea67800b3ea887d9515b37760a5bca81a4a1827afaffe415fb627869f812; anchor=export function connectShell]`. +- Retained: Read complete termios raw-mode sample and parameter setup; existing host shell tool retained. +- Retained: Completed the host-side TCP/stdin path with hostname resolution, incremental Telnet negotiation, backpressure, bounded batch deadlines, hidden interactive authentication, terminal cleanup and no command replay. Actual loopback tests cover success/rejection/auth-required/timeout and interactive disconnect. Existing framed USB and board-side Telnet/TUI remain the real owners. +- Omitted/replaced code: `POSIX-only sample, stop-as-reboot and fake all-operational status`. Disposition: **replaced**. Reason: Use the existing device client/owned USB transport rather than a disconnected demo process or false status. Runtime behavior cannot be fabricated. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Test-code evidence: `tests/telnet_client_test.mjs:19 [UTF8-LF sha256=6d38cafbcf7c44ce351f1b70691dfda3c57132134c2aea52a5abe18f43d00f42; anchor=async function session]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `examples/telnet_server.c` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `779a8e30dba97d85f24e2d3a76d7b3d1a3aeeb4b8066682d90bfdc5bcb0f4295`. + +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Target: `tools/telnet-shell.mjs:49 [UTF8-LF sha256=85d7ea67800b3ea887d9515b37760a5bca81a4a1827afaffe415fb627869f812; anchor=export function connectShell]`. +- Retained: Read entire skeleton; preserved as reference, not wired into production. +- Retained: Completed the host-side TCP/stdin path with hostname resolution, incremental Telnet negotiation, backpressure, bounded batch deadlines, hidden interactive authentication, terminal cleanup and no command replay. Actual loopback tests cover success/rejection/auth-required/timeout and interactive disconnect. Existing framed USB and board-side Telnet/TUI remain the real owners. +- Omitted/replaced code: `Printed server banner and unimplemented bind/accept/session loop`. Disposition: **replaced**. Reason: Pakar already has actual bounded multi-session Telnet admission, authentication and network lifecycle. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Test-code evidence: `tests/telnet_client_test.mjs:19 [UTF8-LF sha256=6d38cafbcf7c44ce351f1b70691dfda3c57132134c2aea52a5abe18f43d00f42; anchor=async function session]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `IMPLEMENTATION.md` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `d024ffc22f99a7c9f50fa68bbaf02b0e722d68b0edaf39512454643e6f1986e2`. + +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Read each asserted requirement and reconciled against actual donor code rather than trusting completion checkmarks. +- Omitted/replaced code: `Thread-safe variable and full editor completeness claims`. Disposition: **replaced**. Reason: No synchronization exists for raw pointers; editor only prints and waits. Existing loop ownership and staged TUI retained. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `include/portable_shell.h` + +Verdict: **scoped-improvement**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `d2f886686945f5df81dea9aa42f8f6c364e7f54c01c614c3d14d6e9d92815fa9`. + +- Target: `firmware/esp/src/PortableShellCore.h:16 [UTF8-LF sha256=4699926c543a4e79d0c750e1379f8497c9e0ad386bb01a22a820339ccd0787b0; anchor=struct Command]`. +- Target: `firmware/esp/src/TelnetShell.h:139 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class LineEditor]`. +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Retained: Adapted PSCommand name/help model and portable callback output into immutable command catalogue/Terminal; independent session state remains in LineEditor. +- Omitted/replaced code: `Heap-linked arbitrary register/unregister, Arduino bool/HIGH/LOW macros, raw mutable parameter pointers`. Disposition: **replaced**. Reason: Build-time immutable descriptors prevent dangling name/callback ownership and duplicate registrations; C++ types remain intact; settings go through validation. +- Test-code evidence: `tests/portable_shell_test.cpp:8 [UTF8-LF sha256=1ce65230a875baf56a04184dc7b586cc7c15946b2fb066b12453759a84afb31f; anchor=int main()]`. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Scoped advantage: Actual command/VT100 integration preserves bounded authenticated owners and adds tested, non-executing command/property completion instead of a Tab bell placeholder. Device TUI adds Unicode frame bytes, bounded NAWS/TTYPE-aware layout, session terminal preferences and role-gated game actions/settings. Full Unicode entry, grapheme/RTL layout, CHARSET negotiation, advanced presentation override editing and live acceptance remain issue #29 work. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `include/ps_io.h` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `024a3ac2231b9b76b3a558a7f81406b5660ba7694c4f9e2f9b7caa585f05bcb1`. + +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Target: `firmware/esp/src/ShellWire.h:9 [UTF8-LF sha256=af3f23d908310dd349b0c90d1fc4385a212e6439b10c72f8dc2f37dace4b5216; anchor=class SerialChannel]`. +- Target: `tools/telnet-shell.mjs:49 [UTF8-LF sha256=85d7ea67800b3ea887d9515b37760a5bca81a4a1827afaffe415fb627869f812; anchor=export function connectShell]`. +- Retained: Read callback/context contracts; preserved per-session transport-independent output and existing explicit USB ownership. +- Retained: Completed the host-side TCP/stdin path with hostname resolution, incremental Telnet negotiation, backpressure, bounded batch deadlines, hidden interactive authentication, terminal cleanup and no command replay. Actual loopback tests cover success/rejection/auth-required/timeout and interactive disconnect. Existing framed USB and board-side Telnet/TUI remain the real owners. +- Omitted/replaced code: `New PSSocketContext/PSUdpContext/PSSerialContext and undefined stdio initializer`. Disposition: **replaced**. Reason: Existing WiFiClient/SerialChannel owners already provide the real transports with authentication and replay fencing; no duplicate socket or serial owner. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Test-code evidence: `tests/telnet_client_test.mjs:19 [UTF8-LF sha256=6d38cafbcf7c44ce351f1b70691dfda3c57132134c2aea52a5abe18f43d00f42; anchor=async function session]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `LICENSE` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `b00e51aca5e080d2fcb8734e9a4779dc917e8df2f38730cca2e82e4ad08184aa`. + +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Target: `firmware/esp/src/PortableShellCore.h:16 [UTF8-LF sha256=4699926c543a4e79d0c750e1379f8497c9e0ad386bb01a22a820339ccd0787b0; anchor=struct Command]`. +- Retained: MIT copyright and complete license retained with original source; derived header points to this notice. +- No production-code omission claimed for this reviewed file. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `Makefile` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `e9d0814b7f08de2a7f53d004192a60cb329652e7b745bdee379144373cb26f4f`. + +- Target: `tools/test-shell.ps1:8 [UTF8-LF sha256=9fb4a11ea30f14cbf306683cdbf1fdfe6042b5a3ddcc13f4a87cfb9f69b0f075; anchor=foreach($taskName]`. +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Read full targets and retained upstream build recipe as reference. Tests integrated into the existing guarded native runner. +- Omitted/replaced code: `Separate donor make clean/install/uninstall and -g profile`. Disposition: **replaced**. Reason: No second build cache or uncontrolled system installation; existing cross-platform canonical low-write test/build wrappers remain authoritative. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `README.md` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `f7228b81aa68c7553cd67100da5c77abe97a8e1ad086e756cdb79c1783cfdcd2`. + +- Target: `third_party/portable-shell/README.md:1 [UTF8-LF sha256=e9792a68877e64c2c7355d9af2dfffb03dc5973ce3d01788c586ceb6f37bbd14; anchor=# Portable Shell source integration]`. +- Retained: Read entire requested branch README, including API examples and completed plan. Useful capabilities mapped to actual compiled owners. +- Omitted/replaced code: `Production-ready/comprehensive claims and example server instructions`. Disposition: **replaced**. Reason: Examples and tests do not implement promised transport/editor behaviors; integration guide records inspected limitations explicitly. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `src/portable_shell.c` + +Verdict: **scoped-improvement**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `d64aef3c23476fe37d5fabdec15b5e9fb5638a200a157a52d06349a587448a45`. + +- Target: `firmware/esp/src/PortableShellCore.h:16 [UTF8-LF sha256=4699926c543a4e79d0c750e1379f8497c9e0ad386bb01a22a820339ccd0787b0; anchor=struct Command]`. +- Target: `firmware/esp/src/TelnetShell.h:139 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class LineEditor]`. +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Target: `firmware/esp/src/ShellTui.h:14 [UTF8-LF sha256=f1da4420e769c7a9da2570603ee70981c894808804d07cb3b8451e093f7d6efd; anchor=class ShellTui]`. +- Target: `firmware/esp/src/ShellLabels.h:5 [UTF8-LF sha256=3ea98be287d73a0188be5295a707d09bb66ad780f81d23554420245979b133cf; anchor=inline const char* propertyLabel]`. +- Target: `firmware/esp/src/ShellToneCommand.h:8 [UTF8-LF sha256=4bcb091529777d891c2f435724ef9531d4417f098ecb5ee2914d1e16d7e55a7a; anchor=inline bool toneCommand]`. +- Retained: Adapted strcmp command lookup and name/help metadata into the compiled catalogue; expanded Tab placeholder into safe completion plus descriptor-only muted suggestions accepted by Right/End, role-filtered grouped/wrapped Usage and a real validated finite beep route. TUI labels are presentation names, not changed API keys. +- Omitted/replaced code: `ps_create/destroy/run, heap history, strtok argv, synchronous escape reads, raw-pointer atoi/atof setters, blocking edit loop`. Disposition: **replaced**. Reason: Existing fixed/scrubbed history, incremental parser, output backpressure, property validation and staged TUI prevent allocation failures, secret retention, truncated execution and blocking LED/UART work. +- Test-code evidence: `tests/portable_shell_test.cpp:8 [UTF8-LF sha256=1ce65230a875baf56a04184dc7b586cc7c15946b2fb066b12453759a84afb31f; anchor=int main()]`. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Test-code evidence: `tests/shell_tone_dispatch_test.mjs:49 [UTF8-LF sha256=39e1e5a861b30040d05652b476789f6d3e6375c42d8ba535d6bf95be042612e2; anchor=Actual beep-to-buzzer-to-AVR branch]`. +- Scoped advantage: Actual command/VT100 integration preserves bounded authenticated owners and adds tested, non-executing command/property completion instead of a Tab bell placeholder. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `src/ps_io_socket.c` + +Verdict: **equivalent-behavior**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `1eb80f98d09fb1042fee3f5facb9dfc3813962b0645c05307c701064aa6e6df6`. + +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Target: `firmware/esp/src/ShellWire.h:9 [UTF8-LF sha256=af3f23d908310dd349b0c90d1fc4385a212e6439b10c72f8dc2f37dace4b5216; anchor=class SerialChannel]`. +- Target: `tools/telnet-shell.mjs:49 [UTF8-LF sha256=85d7ea67800b3ea887d9515b37760a5bca81a4a1827afaffe415fb627869f812; anchor=export function connectShell]`. +- Retained: Read all TCP/UDP/serial implementation; preserved original for review. Existing actual nonblocking Telnet and encapsulated USB remain single transport owners. +- Retained: Completed the host-side TCP/stdin path with hostname resolution, incremental Telnet negotiation, backpressure, bounded batch deadlines, hidden interactive authentication, terminal cleanup and no command replay. Actual loopback tests cover success/rejection/auth-required/timeout and interactive disconnect. Existing framed USB and board-side Telnet/TUI remain the real owners. +- Omitted/replaced code: `Blocking IPv4-only connect/recv, single partial send, unconnected UDP replies, serial init=-1 and no-op close`. Disposition: **replaced**. Reason: Avoid loop stalls, dropped output, invalid UDP destinations and fake serial support; hostname-first discovery and binary recovery are already shared project facilities. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Test-code evidence: `tests/telnet_client_test.mjs:19 [UTF8-LF sha256=6d38cafbcf7c44ce351f1b70691dfda3c57132134c2aea52a5abe18f43d00f42; anchor=async function session]`. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `src/vt100.c` + +Verdict: **scoped-improvement**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `fa151f1510abac7ecef369c512b9ce1132f14e37b0691a74ea2bfcef53d3e9b6`. + +- Target: `firmware/esp/src/PortableShellCore.h:16 [UTF8-LF sha256=4699926c543a4e79d0c750e1379f8497c9e0ad386bb01a22a820339ccd0787b0; anchor=struct Command]`. +- Target: `firmware/esp/src/TelnetShell.h:139 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class LineEditor]`. +- Target: `firmware/esp/src/ShellService.h:192 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void executeDeviceShell]`. +- Retained: Adapted actual ANSI clear/line/cursor/color/reset formatting into bounded callback emitters and used them by the live LineEditor and clear command. +- Omitted/replaced code: `Unbounded signed cursor/color formatting and unused standalone cursor-home/right wrappers`. Disposition: **replaced**. Reason: Validate coordinate/SGR ranges; existing editor retains echo-free password handling, plain-terminal behavior and movement semantics without duplicated control owners. +- Test-code evidence: `tests/portable_shell_test.cpp:8 [UTF8-LF sha256=1ce65230a875baf56a04184dc7b586cc7c15946b2fb066b12453759a84afb31f; anchor=int main()]`. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Scoped advantage: Actual command/VT100 integration preserves bounded authenticated owners and adds tested, non-executing command/property completion instead of a Tab bell placeholder. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + +### portable-shell: `tests/test_shell.c` + +Verdict: **scoped-improvement**. Revision: `67c5dcb8a63f4055cbdaf02e9ff271c53b46410d`. Original-byte SHA-256: `f26c767cd4ab7471a586b3b1500f9a613cc0ce00ac3f0ad23733e0c04af526eb`. + +- Target: `tests/portable_shell_test.cpp:8 [UTF8-LF sha256=1ce65230a875baf56a04184dc7b586cc7c15946b2fb066b12453759a84afb31f; anchor=int main()]`. +- Target: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Retained: Read all eight donor test scenarios; retained original tests and adapted independent-context, built-in, command-lookup, line-edit and VT100 output assertions into production-header fixtures. +- Omitted/replaced code: `Mock no-crash checks as completeness evidence`. Disposition: **replaced**. Reason: New tests assert exact bytes, safe aliases, bounds, password isolation, ambiguous/property completion and fragmented escape handling, alongside existing history/TUI/USB/lease tests. +- Test-code evidence: `tests/portable_shell_test.cpp:8 [UTF8-LF sha256=1ce65230a875baf56a04184dc7b586cc7c15946b2fb066b12453759a84afb31f; anchor=int main()]`. +- Test-code evidence: `tests/portable_shell_source_test.mjs:5 [UTF8-LF sha256=a648b3e984600cefb2c81f3acd898d57bfd39a47453b6684993023bb4b1fcb5b; anchor=const source=]`. +- Scoped advantage: Actual command/VT100 integration preserves bounded authenticated owners and adds tested, non-executing command/property completion instead of a Tab bell placeholder. +- Remaining: Issue #29: source/fixture acceptance only; no production flash or live USB/Telnet session acceptance in this integration task. + ### PU850-ESP: `.gitattributes` Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d7fdbf0bc704830296234f2143889503714b9798941d52de7d8c4de593862fcc`. -- Target: `.gitattributes:1 [UTF8-LF sha256=613bba2c932de973fc1d5d117b0ea5d59d338fd0e9c9cd37b5b8afc37b05ec39; anchor=* text=auto eol=lf]`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `.gitattributes:1 [UTF8-LF sha256=3f36f7f02cd536503301801b4961dbc09a1ba4fb2c1ed0dad759be1d02ee61b9; anchor=* text=auto eol=lf]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: cmd/bat/ps1 CRLF; sh/yml/yaml LF - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Donor script-specific CRLF policy`. Disposition: **replaced**. Reason: Current LF text/binary rules work with the actual cross-platform interpreters; no CRLF runtime requirement. @@ -3927,7 +4438,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `.github/dependabot.yml:3 [UTF8-LF sha256=136887ba77b56544d0bcd367a31d2daaa7637ecdb8b997d63df6cb552d6f08e8; anchor=package-ecosystem: github-actions]`. - Retained: Read exact ecosystem/schedule/group settings and retain Pakar's existing pinned Action/Cargo/pub maintenance. Firmware registry pins use the new dedicated checker instead of pretending Dependabot understands Arduino board/library indices. - Omitted/replaced code: `Unconditional latest-success/first-binary auto-flash and donor-specific test/product packaging`. Disposition: **replaced**. Reason: Use exact successful main source/run/artifact identity and existing Pakar jobs. Automatic writes require an explicitly authorized runner/environment and repository opt-in. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Execute actual new GitHub jobs; verify bot policy and authorized runner/environment before activation. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -3936,11 +4447,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `49d608184ca52242cc845026d05384081669eec57007733f43ede09539c79cb6`. -- Target: `.github/workflows/build.yml:104 [UTF8-LF sha256=83a9772a380f29a70886fad3b0462c3f17aa0c46f565d9feec32c57aba37c498; anchor=name: "🔧 ESP32-S3 + AVR"]`. +- Target: `.github/workflows/build.yml:106 [UTF8-LF sha256=2011a5c6db4cce2042b223c1724d4fc6481d140f7aaf68527bc0f8ccf79c363e; anchor=name: "🔧 ESP32-S3 + AVR"]`. - Target: `tools/firmware-report.mjs:57 [UTF8-LF sha256=a374a5172a8e9d366e1bbe124311004e2f093ab648e94a54cd8c4a1ca18b4423; anchor=export function artifactMarkdown]`. - Retained: Adapt actual artifact size/checksum/download summary and staged build workflow mechanisms into existing Pealayer-derived build.yml plus firmware-report.mjs. Retain separate matched firmware outputs and draft-release provenance; no external progress image or rewritten timestamp header. - Omitted/replaced code: `Unconditional latest-success/first-binary auto-flash and donor-specific test/product packaging`. Disposition: **replaced**. Reason: Use exact successful main source/run/artifact identity and existing Pakar jobs. Automatic writes require an explicitly authorized runner/environment and repository opt-in. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Execute actual new GitHub jobs; verify bot policy and authorized runner/environment before activation. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -3949,11 +4460,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `06b31df1731e5ab45fb0f1c714f95c46470cf1006f6c50150dc9a461d95a52d0`. -- Target: `.github/workflows/build.yml:72 [UTF8-LF sha256=83a9772a380f29a70886fad3b0462c3f17aa0c46f565d9feec32c57aba37c498; anchor=name: "🧰 Scripts · ${{ matrix.os }}"]`. +- Target: `.github/workflows/build.yml:74 [UTF8-LF sha256=2011a5c6db4cce2042b223c1724d4fc6481d140f7aaf68527bc0f8ccf79c363e; anchor=name: "🧰 Scripts · ${{ matrix.os }}"]`. - Target: `.github/actionlint.yaml:3 [UTF8-LF sha256=5b3c6b07ca38aaadb14a1d6eb2d35ac83b705f065901fdafba8210afc9be4960; anchor=pakar-deploy]`. - Retained: Adapt actual tooling verification intent into Windows/Linux/macOS fixture/plan matrix before product jobs. Use one Node/PowerShell owner with explicit Windows-only utility boundaries; no donor-client build/test execution is falsely certified. - Omitted/replaced code: `Unconditional latest-success/first-binary auto-flash and donor-specific test/product packaging`. Disposition: **replaced**. Reason: Use exact successful main source/run/artifact identity and existing Pakar jobs. Automatic writes require an explicitly authorized runner/environment and repository opt-in. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Execute actual new GitHub jobs; verify bot policy and authorized runner/environment before activation. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -3966,7 +4477,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/firmware-dependencies.mjs:77 [UTF8-LF sha256=903bd1fdd6e50218cab22db4fc21fb64aee44da2b025faf329fcee4703506ab1; anchor=export function applyUpdates]`. - Retained: Adapt scheduled registry check and guarded pin-change PR proposal into update-dependencies.yml. Report-only default, exact locked pin file, draft/no-auto-merge, pinned Actions and no device or SDK writes. - Omitted/replaced code: `Unconditional latest-success/first-binary auto-flash and donor-specific test/product packaging`. Disposition: **replaced**. Reason: Use exact successful main source/run/artifact identity and existing Pakar jobs. Automatic writes require an explicitly authorized runner/environment and repository opt-in. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Execute actual new GitHub jobs; verify bot policy and authorized runner/environment before activation. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -3979,7 +4490,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/deploy-artifact.mjs:8 [UTF8-LF sha256=0710b78d5359b0d6ece4dd48f9129acebe1809c370ed4bb6b6767fd4917bf55d; anchor=export function trustedBuild]`. - Retained: Adapt completed-build artifact-to-device flow into opt-in upload.yml/deploy-artifact.mjs. Verify exact repository/head/event/main workflow/run/SHA plus clean artifact report/hash before invoking the same current updater; no guessed latest/first binary. - Omitted/replaced code: `Unconditional latest-success/first-binary auto-flash and donor-specific test/product packaging`. Disposition: **replaced**. Reason: Use exact successful main source/run/artifact identity and existing Pakar jobs. Automatic writes require an explicitly authorized runner/environment and repository opt-in. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Execute actual new GitHub jobs; verify bot policy and authorized runner/environment before activation. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -3989,7 +4500,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `4e8df980915336db111442a9099dfefd1e01287b7925073f804e1726012e2059`. - Target: `.gitignore:10 [UTF8-LF sha256=2d5301c89aab48d6108d9c12c7c7a1ac32514aa3628944d07bc70463e080059c; anchor=~secrets.h]`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: logs/prerequisites/objects/link/PCH/library/executable/debug/kernel/IDE/build/SDK/private-header/Node/uploader/workflow rules - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Donor product uploader output directories`. Disposition: **replaced**. Reason: Current target build/target and service node_modules exclusions apply; actual ~local.h/~secrets.h patterns are imported. @@ -3998,11 +4509,11 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `862e8979a82f1ecf8226b1762e72c265b15bb126275db3fabb8563c7473e6320`. -- Target: `tools/firmware-dependencies.json:2 [UTF8-LF sha256=dc64a7f76dbb2272bcd6f35a51623a372e6bff55256ae588f02a2a4d315d3207; anchor="platforms"]`. +- Target: `tools/firmware-dependencies.json:2 [UTF8-LF sha256=95a5da284e415b35c47d8f4e9048381ec41c3e181261c437ec91b9cdcf1090c1; anchor="platforms"]`. - Target: `tools/firmware-dependencies.mjs:34 [UTF8-LF sha256=903bd1fdd6e50218cab22db4fc21fb64aee44da2b025faf329fcee4703506ab1; anchor=export function installationPlan]`. - Retained: Retain official ESP8266 registry URL and extend it in one central pin file for ESP32-S3/MiniCore. Explicit install adds only missing configured registry URLs, never initializes/replaces operator SDK configuration. - Omitted/replaced code: `PU-specific board/profile and separate dependency/config write paths`. Disposition: **replaced**. Reason: Reuse applicable exact versions/official registries in one reviewed pin owner. Preserve operator config and Pakar hardware flags; no silent downgrade, duplicate Arduino_JSON API, unsupported library additions or automatic SDK initialization. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run live registry check and review any candidate version changes with full build/hardware acceptance; pins were not upgraded by this slice. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4012,7 +4523,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d350ffb36676dca266ae2bb3eca20620f9eabca922ff7d5d271ba8617c3acca4`. - Target: `.vscode/tasks.json:5 [UTF8-LF sha256=2681847c279d80dc8b8f0683feb126746498f73e1cd2eed4b9e9071c0ebf846d; anchor=Pakar: firmware plan]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: configuration/board/sketch/port/output/prebuild/postbuild/buildPreferences - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Old host/profile/postbuild commands`. Disposition: **pending**. Reason: Actual shared wrapper/tasks preserve profiles/cache gates; no COM13, disabled exception/stack guards, forced gzip/clean or deletion. Current compiler-database/editor execution remains pending. @@ -4022,7 +4533,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `264b32b11c64efad4d8198700184d40806e8ec7a60ad7e41c0aab9f91b48d661`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: version/configurations; compilerPath/compilerArgs/intelliSenseMode; every includePath/forcedInclude/cStandard/cppStandard/exported define - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Current compilation database and S3 IntelliSense`. Disposition: **pending**. Reason: Full 19KB blob read; donor hardcodes host SDK/ABI and contradictory compiler macros. Derive actual current compile flags/database before claiming this generic capability. @@ -4035,7 +4546,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/firmware-report.mjs:67 [UTF8-LF sha256=a374a5172a8e9d366e1bbe124311004e2f093ab648e94a54cd8c4a1ca18b4423; anchor=const source_sha=execFileSync]`. - Retained: Adapt actual editor preflight entrypoint to the shared Windows launcher and build wrapper. The same target/link flags and generated-file checks now apply to editor, local CLI and CI calls. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4047,7 +4558,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/firmware-report.mjs:67 [UTF8-LF sha256=a374a5172a8e9d366e1bbe124311004e2f093ab648e94a54cd8c4a1ca18b4423; anchor=const source_sha=execFileSync]`. - Retained: Adapt actual shell preflight/delegation into pakar.sh/build.sh and the same generated-asset/donor build guards used on Windows. No second editor-only compiler path. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4058,7 +4569,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `.vscode/settings.json:5 [UTF8-LF sha256=2373a0d9d9a7af54872b0ae854ccf1ff95116ef8f80aebfad513fe3031914d5f; anchor="*.ino": "cpp"]`. - Target: `.vscode/tasks.json:5 [UTF8-LF sha256=2681847c279d80dc8b8f0683feb126746498f73e1cd2eed4b9e9071c0ebf846d; anchor=Pakar: firmware plan]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: every editor indentation/folding/default language/spelling/association/exclude/search/error/formatting setting - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Full current editor integration/acceptance`. Disposition: **pending**. Reason: Actual relevant associations/vocabulary/generated-path filters imported. Preserve personal indentation/theme/format settings; broad dotfile/assembly/hex hiding would conceal current source/policy. Actual editor acceptance remains open. @@ -4068,7 +4579,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `46ffefb855ae7f6c22d6b78ea7cebffe627a0ddae89cbf7918b4c80dcc9cda2a`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: pragma, provisioning comments and token placeholder - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Separate compile-time Bearer credential owner`. Disposition: **replaced**. Reason: Use existing physical-USB AccessConfig and environment-only client credentials, no additional bearer bypass or copied placeholder. Ignore patterns are imported. @@ -4077,7 +4588,7 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d90e55ab89ecdc3052d1a5863472e6b6524d343ddbda4ff4e15b95b88ab256b4`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: folder declaration; every files.associations entry; cSpell words - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Editor associations and vocabulary`. Disposition: **pending**. Reason: Import applicable Pakar associations/shared build ergonomics in a later editor slice; no foreign machine paths. @@ -4088,9 +4599,9 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `773bfaf35ae5099f4676653fbf0ed326b508e1107828ac72337e0b0f2db8a93b`. - Target: `libraries/BoardCore/src/DiagnosticText.h:11 [UTF8-LF sha256=70fc9b47f889046b09b97e70bc07c24e5a9c3e7b1fc4e3c0e4626c03ce63afb5; anchor=inline bool encodeHex(]`. -- Target: `firmware/esp/src/WebAccess.h:292 [UTF8-LF sha256=314ca16a853e550f62f71211c3607e1c27d5c9c488484f7168eb27ec85e45ca2; anchor=BoardCore::DiagnosticText::encodeHex(bytes]`. -- Target: `firmware/esp/src/MaintenanceRoutes.h:192 [UTF8-LF sha256=3e2ca1562541eea7218a6740c0ac9d2eba319072674fc0e0a3e86110a292adfc; anchor=e[F("uptime_text")]=uptimeText]`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `firmware/esp/src/WebAccess.h:293 [UTF8-LF sha256=774b524f4cd81c003e4139d0bb6cf3353d987674bbfb49d7a1b989ee2a232294; anchor=BoardCore::DiagnosticText::encodeHex(bytes]`. +- Target: `firmware/esp/src/MaintenanceRoutes.h:196 [UTF8-LF sha256=cc32c9d0b953c94e9a7bc8677cf171ea1a7695edc373e7f6911667db21f54abc; anchor=e[F("uptime_text")]=uptimeText]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Actual convertToHexString/getFormattedUptime/humanReadableBytes loops/decomposition/scaling imported as fixed-buffer DiagnosticText and invoked by live session, /random and telemetry owners. - Retained: Every section/function read through EOF: include/debug graph; flashSettings/state/callbacks/mode/count/range/hex macros; convertToHexString/parseHexString; reflectU32/calculateCRC32/calculateReflectedCRC32; calculateMD5/getRandomMD5/stringMD5; isValidHostName/urlEncode/getBasename/getFileExtension/extractFileName/startsWith/endsWith/startsWithIgnoreCase; isLeapYear/isValidDate/isValidTime/monthNames/dayNames/monthStringToInt/dayOfWeek; TimeStamp constructors/set/toEpoch/fromEpoch/adjusted/isValid/toString/PU_DateTime/parseDateTime overloads; getFormattedUptime/humanReadableBytes; compiler/debug/serial tail - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. @@ -4102,7 +4613,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `733c09461007e10e55de248dffd1eecf6e8e615b6d5be57de59cd00cb859444f`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: pragma once and early Arduino CLI options documentation; no declarations - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Empty product-named globals header`. Disposition: **replaced**. Reason: Current generated build identity and explicit build flags own options; the donor file has no implementation. @@ -4117,7 +4628,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `pakar.cmd:8 [UTF8-LF sha256=d09fcf080f3134ef52b4d286427b1dfe42897ca28ea18595cb89405829c7fa1d; anchor=tools\pakar.mjs]`. - Retained: Retain actual PU argument/help/preflight/build-stage semantics behind a small Windows launcher. Firmware mode/target validation and diagnostics now share tools/pakar.mjs with Bash instead of maintaining a second compiler implementation. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4129,10 +4640,13 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `build.sh:5 [UTF8-LF sha256=ddc2c39d73af52a2a233fb6ba8c17c9e281c5ba095455e39c0b55b227c607985; anchor=pakar.sh]`. - Target: `tools/pakar.mjs:35 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export function legacyBuildArguments]`. - Target: `pakar.sh:8 [UTF8-LF sha256=0e5554e3fb7a614178d57a8a39f081862a1ac382e5e4d87e9806b56d4512c680; anchor=exec node tools/pakar.mjs]`. +- Target: `tools/resource-report.mjs:55 [UTF8-LF sha256=6aa6044ed6813ed6a3d8028ef7efbf71e838d8efbd07ba228bd6e09bd23ef6a5; anchor=export function collectBuild]`. - Retained: Retain PU mode/target preflight and explicit build stages through the same portable dispatcher and guarded compiler wrapper. Artifact size/hash and optional gzip reporting live in firmware-report.mjs instead of shell parsing. +- Retained: Resource reporting extends the existing PU size/hash adaptation with real OTA-slot and AVR ELF/static budgets, retained CI baselines and separate timestamped read-only live observations. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. +- Test-code evidence: `tests/resource_report_test.mjs:43 [UTF8-LF sha256=90947f5b882e8204f3609d0d29d12068bfc0de8d1b5ce09e57a0bef6e26de376; anchor=Resource reports:]`. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4140,7 +4654,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d04aedb14faa7caa7b47d438605ec82da2b8281ce62bc6cfd0a4dd8db0d715cf`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: Arduino; USE_LOCALH; ignored secret inclusion; ProductConfig; serial-shell alias; every Temp dependency - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic Temp network/file/date/session/UI/programmer contracts`. Disposition: **pending**. Reason: All four dependency blobs are fully read; reconcile actual UART functions and current owners without copying macro protocol, secret or serial alias. @@ -4155,16 +4669,16 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tests/usb_api_request_test.cpp:34 [UTF8-LF sha256=892657b7e5e2e2f2a6d3bde565136fd40373eb4d4243929c9a9faf6a4d111774; anchor=USB API replay, bounds, expiry]`. - Target: `firmware/esp32s3/src/OtaStage.h:9 [UTF8-LF sha256=38f573b619915ff3c09062d4198aef6d2cc4b44a25fb72ec187edb33a8f22f9b; anchor=class S3OtaStage]`. - Target: `firmware/esp/src/RemoteFirmware.cpp:325 [UTF8-LF sha256=ad02224ab50dde4e3f34367003d9c9ddad3198722cb5aac7bcb51bd935f81f4a; anchor=bool RemoteFirmware::validateEspStage()]`. -- Target: `firmware/esp/src/MaintenanceRoutes.h:299 [UTF8-LF sha256=3e2ca1562541eea7218a6740c0ac9d2eba319072674fc0e0a3e86110a292adfc; anchor=if(plan.targets==RemotePolicy::Esp && !programmingLatched) return true;]`. +- Target: `firmware/esp/src/MaintenanceRoutes.h:304 [UTF8-LF sha256=cc32c9d0b953c94e9a7bc8677cf171ea1a7695edc373e7f6911667db21f54abc; anchor=if(plan.targets==RemotePolicy::Esp && !programmingLatched) return true;]`. - Target: `firmware/esp/src/ArtifactDownload.h:37 [UTF8-LF sha256=c37444ba8adb83a722b27a913c820da07e948b7afce4c392ccdce58ff6943b83; anchor=remoteFirmware.readStagedEsp]`. - Target: `firmware/esp32s3/src/DnsCoreLock.cpp:13 [UTF8-LF sha256=5b4540c3bab1666c2b1da6b940f580b67a4dc66ce9e19ecddffcee9d08d30f2a; anchor=extern "C" void __wrap_dns_clear_cache]`. -- Target: `firmware/esp/src/AsyncHttpService.h:128 [UTF8-LF sha256=50a432a62e484ae5f3bf59ffb5a9642ef66d7f07a5743402accb664056840d02; anchor=AsyncURIMatcher::exact(path)]`. +- Target: `firmware/esp/src/AsyncHttpService.h:128 [UTF8-LF sha256=646e7e4df17e61feb4db3ce3d19b5f9e48329e16d6c190724f4d6f5621fdc499; anchor=AsyncURIMatcher::exact(path)]`. - Target: `firmware/esp32s3/src/DnsRecovery.h:13 [UTF8-LF sha256=dd0edc8470b2d8073176e8328bae8bba5b3be84137381a6874f990f7bc511869; anchor=class DnsRecovery]`. - Target: `firmware/esp/src/RemoteFirmware.h:107 [UTF8-LF sha256=9c959a74a7d59909f38c48844e191a089cf1849663e78caa4a3fafcaff6b2251; anchor=Declaration order keeps that client alive]`. - Target: `firmware/esp32s3/src/LedFlashGuard.cpp:37 [UTF8-LF sha256=8e09da7e63706aa1927eaba068a7a74a32f651840d1ce597977242d67fc1ce1f; anchor=esp_err_t IRAM_ATTR __wrap_esp_partition_write(]`. - Target: `firmware/esp/src/CloudServices.cpp:417 [UTF8-LF sha256=88444770a73ac1ed410cdddce847603bf961d30bc7585da1c35bf128766b4901; anchor=void CloudServices::handleCommand(]`. - Target: `firmware/esp/src/CloudServices.cpp:295 [UTF8-LF sha256=88444770a73ac1ed410cdddce847603bf961d30bc7585da1c35bf128766b4901; anchor=bool CloudServices::publishMqtt(]`. -- Target: `firmware/esp/src/CloudManagement.h:86 [UTF8-LF sha256=403b8700c458b5eaa3f04b2fe7e1cf9112255d17731a45d551cab74d19b04365; anchor=bool updateCloudConfiguration(]`. +- Target: `firmware/esp/src/CloudManagement.h:86 [UTF8-LF sha256=4f13b83818ebd6f459ad3efd4f251771871a51402384835e9cf9d3d07d8cec85; anchor=bool updateCloudConfiguration(]`. - Target: `firmware/esp/src/NetworkPath.h:56 [UTF8-LF sha256=cab7eb5a6e7985dfb8e5120b19991b9d434bcb4f083d5509678636b5adbda662; anchor=inline bool service()]`. - Target: `libraries/BoardCore/src/CloudCommandGuard.h:33 [UTF8-LF sha256=2259897f8275dbd6eb7e0254b00dfc589e88c9af35dc7d2267cd749f4cb12a3d; anchor=class Guard]`. - Target: `firmware/esp/src/CommandValues.h:8 [UTF8-LF sha256=5ab14e5c77b2c6eb2182592fcdf32526b50bef2a3b6904fb1e8aff2fd2b7b5de; anchor=namespace PakarCommand]`. @@ -4191,9 +4705,9 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `All-client progress/outcome persistence, bounded DNS/TLS opening and complete staged export concurrency`. Disposition: **pending**. Reason: Existing native IPC acceptance did not expose a durable dispatch outcome; HTTP synchronous connect/DNS still has SDK-bound blocking. Range/lease integration has static/SDK checks but still needs live slow-client contention and credential/owner isolation testing. - Test-code evidence: `tests/remote_s3_test.cpp:125 [UTF8-LF sha256=fc24e91c18d474281cfad8c65a351a68e4bcb9e64fd49175b668529da2a0a9bc; anchor=Actual S3 RemoteFirmware]`. - Test-code evidence: `tests/remote_tools_test.mjs:11 [UTF8-LF sha256=4a38cbf8a24751720b35f940e894a64bcd1689b1402972feeb69fa079603e760; anchor=const solo=manifest]`. -- Test-code evidence: `tests/s3_platform_wiring_test.mjs:45 [UTF8-LF sha256=2f224ecd6b8eade6e985bf64244690a8a93347fe6e95ab0b4646edfaa823412a; anchor=const flashRead =]`. +- Test-code evidence: `tests/s3_platform_wiring_test.mjs:45 [UTF8-LF sha256=778c0fee4df01440d52814b64363f0fba85d235edc457b4bae2739e94cf4e662; anchor=const flashRead =]`. - Test-code evidence: `tests/s3_dns_lock_test.cpp:12 [UTF8-LF sha256=0c28b63dfedb4224d1ba1fde2f9ac98179515b2fdc8858fa25282b882315dc50; anchor=reset();nested=true;dns_clear_cache();]`. -- Test-code evidence: `tests/async_http_test.cpp:43 [UTF8-LF sha256=aa31ba27f15b74ba5cd1f61133bc261776c09637b22a4f7d6365b9208867fc97; anchor=Reproduce installed SDK3.12.1]`. +- Test-code evidence: `tests/async_http_test.cpp:44 [UTF8-LF sha256=1d3dc679c85dc177b7d255ad8a46eaa2d38f022f3f248d8c0720433aad50a9de; anchor=Reproduce installed SDK3.12.1]`. - Test-code evidence: `tests/access_stubs/ESPAsyncWebServer.h:125 [UTF8-LF sha256=7ae9dace972d3bb2384ce0b08bac1870938968e1aa271f2b9d839df3cd8794eb; anchor=if(onPause)onPause();]`. - Test-code evidence: `tests/s3_dns_recovery_test.cpp:23 [UTF8-LF sha256=c41d7526e9983da068a7c23db8dcad9cf0b4589b4f94ca1ad08d939e56a1fa8a; anchor=DnsRecovery promote;]`. - Test-code evidence: `tests/remote_stubs/ESP8266HTTPClient.h:22 [UTF8-LF sha256=b9e28f67f69f78a8e29c84c7f00e578de287c4e6d51a250bda9164fa4d6f9796; anchor=Actual S3 end() retains _client]`. @@ -4203,16 +4717,16 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Test-code evidence: `tests/cloud_command_guard_test.cpp:17 [UTF8-LF sha256=2de44ca916fe1670eba11edeb706e88dfdafc4994d0a9d07301a279ef71c0581; anchor=int main()]`. - Test-code evidence: `tests/cloud_services_test.cpp:65 [UTF8-LF sha256=9d73f8c4c8ce53c485644ef67da077be526fd244b51e41534ef784df529d1331; anchor=int main()]`. - Test-code evidence: `tests/cloud_config_test.cpp:47 [UTF8-LF sha256=a23cf2353e93b71c1d69f218a0dd4a37c13bd8c976bbe61ab2e14edf82431b15; anchor=int main()]`. -- Test-code evidence: `tests/cloud_management_test.cpp:48 [UTF8-LF sha256=5a7cccb2839bb3869b9f5c85f5de3a181873ceb2d7f50171a7ed013a94db2df3; anchor=int main()]`. +- Test-code evidence: `tests/cloud_management_test.cpp:49 [UTF8-LF sha256=bd562de830d4688831338d8fc2bd30fa228a1405f0351e55edddbc79b8d55065; anchor=int main()]`. - Test-code evidence: `tests/command_values_test.cpp:17 [UTF8-LF sha256=99f8a78b05de3080ab8bfc32ce623094a13b1f807f4f43a987210de1e05375bc; anchor=int main()]`. - Test-code evidence: `tests/ethernet_config_test.cpp:19 [UTF8-LF sha256=947b9a7062895d4d06083652e8952814e5136ac115b773d6b7f8ba089812c4ff; anchor=int main()]`. -- Test-code evidence: `tests/ethernet_management_test.cpp:67 [UTF8-LF sha256=831f71b7b3f4e357bb3efd751ae8a087212fd990492fa47596f110c5a4ddc1ad; anchor=int main()]`. +- Test-code evidence: `tests/ethernet_management_test.cpp:68 [UTF8-LF sha256=7c17e3d485afaccfa6a723ca4d97adba33fbf9bebf3d8d706fe86ea5887afa9a; anchor=int main()]`. - Test-code evidence: `tests/s3_ethernet_port_test.cpp:76 [UTF8-LF sha256=0985aaf25e5ec0d41ae3fcbbb6ef41766a8d2bd07bdb2e388e390b9721191452; anchor=No background retry/reset storm.]`. -- Test-code evidence: `tests/ethernet_management_test.cpp:76 [UTF8-LF sha256=831f71b7b3f4e357bb3efd751ae8a087212fd990492fa47596f110c5a4ddc1ad; anchor=auto offline=snapshot(ethernetStatusJson());]`. +- Test-code evidence: `tests/ethernet_management_test.cpp:77 [UTF8-LF sha256=7c17e3d485afaccfa6a723ca4d97adba33fbf9bebf3d8d706fe86ea5887afa9a; anchor=auto offline=snapshot(ethernetStatusJson());]`. - Test-code evidence: `tests/ethernet_live_tool_test.mjs:17 [UTF8-LF sha256=6a0c0fcb764500ba41a918169306e417cbb94548a872dcccdbc424237bbcf9d9; anchor=for(const scenario of []`. - Test-code evidence: `tests/cloud_services_test.cpp:174 [UTF8-LF sha256=9d73f8c4c8ce53c485644ef67da077be526fd244b51e41534ef784df529d1331; anchor=A slow failed connect must wait a full interval AFTER completion]`. - Test-code evidence: `tests/filesystem_telemetry_test.cpp:17 [UTF8-LF sha256=188dc83c627b88b6359d616676a6c5f58b5dcb281c1db4673aebe879a9dfb0c3; anchor=Thousands of ordinary telemetry snapshots]`. -- Test-code evidence: `tests/filesystem_telemetry_test.mjs:19 [UTF8-LF sha256=fcea3053befe897b11dbf43f03420ea299dbdbfabef28935cb00c3c2e8f6ecd2; anchor=cachedTelemetry(routes,main)]`. +- Test-code evidence: `tests/filesystem_telemetry_test.mjs:19 [UTF8-LF sha256=10d66d713caf36f3807e480d945879306c45559a67ddd2165d0388e452d3d6d9; anchor=cachedTelemetry(routes,main)]`. - Scoped advantage: Explicit inactive-slot staging with independent apply, instead of an image that cannot fit its filesystem or an upload that chooses boot before separate consent. USB provisioning continuation reread the actual AsyncOTA source/header: its automatic filesystem upload/final boot selection is not copied. New bounded transaction and erased-partition guards have native fixture proof only. UsbApiService integration is uncompiled because the existing WebUi donor gate is stale. USB firmware upload, atomic settings restore, file writes, optional cloud sync, single-pass installer integration and physical interruption acceptance remain unfinished. - Remaining: Changed-image Internet stage/separate native CLI Apply/new-boot VALID, automatic NTP, complete1853328-byte boot-fenced running SHA and ASUS GUI exclusive hostname Wi-Fi online readback pass for the quiet S3 profile; final image was not USB-flashed. Complete raw export needed29 exact206 ranges and four read retries, not reliable single-stream/client resume. Finish intermittent empty/partial HTTP diagnosis, discovery/AP HTTP recovery, external-power/optical and OTA concurrency/abort/power-loss acceptance. Verify cloud commands without the ASUS relay independently. Preserve distinct USB/LAN/Internet proof and keep AVR, donor-wide parity and production issue2 acceptance open. - Remaining: Latest standalone/ETH integration still requires actual ESP MQTT/WSS broker connectivity, authenticated remote command/telemetry/configuration, absent-AVR Internet image stage/apply/reboot, unplugged-PC recovery and W5500 DHCP/static/link-loss acceptance. Host/source fixtures and SDK builds do not establish those physical/Internet properties. Native/Web/mobile Ethernet editors now have ACK/readback fixtures and built client packages, but current installed-client and physical Ethernet acceptance remain pending. @@ -4221,11 +4735,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `a05952f7ca3241aa840e024ff01560cea3d74226b003e2781efe95b4a533d388`. -- Target: `firmware/esp/application.ino:392 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=std::atomic otaRebootAt{0};]`. -- Target: `firmware/esp/src/AsyncHttpService.h:107 [UTF8-LF sha256=50a432a62e484ae5f3bf59ffb5a9642ef66d7f07a5743402accb664056840d02; anchor=explicit AsyncHttpService(uint16_t port) : native_(port) {}]`. +- Target: `firmware/esp/application.ino:430 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=std::atomic otaRebootAt{0};]`. +- Target: `firmware/esp/src/AsyncHttpService.h:107 [UTF8-LF sha256=646e7e4df17e61feb4db3ce3d19b5f9e48329e16d6c190724f4d6f5621fdc499; anchor=explicit AsyncHttpService(uint16_t port) : native_(port) {}]`. - Retained: Complete original header reread through EOF. Retains authenticated chunked application-image upload, explicit update completion/reboot and shared server integration through the adapted existing Pakar /update handlers. Native W5500 uses the same lwIP listener/updater and authorization policy; no parallel Ethernet-only firmware or server. - Omitted/replaced code: `Global BusyOTA and InitAsyncOTA declarations / ESP8266 updater ownership`. Disposition: **replaced**. Reason: Pakar owns admission, upload lease, staged firmware and deferred reboot explicitly in its integrated main/MaintenanceRoutes. Reintroducing donor globals would create a second mutable owner and bypass paired programming/backup/peer fences. ESP32-S3 uses native OTA partitions, not ESP8266 raw linker/FS bounds. -- Test-code evidence: `tests/async_http_test.cpp:43 [UTF8-LF sha256=aa31ba27f15b74ba5cd1f61133bc261776c09637b22a4f7d6365b9208867fc97; anchor=Reproduce installed SDK3.12.1]`. +- Test-code evidence: `tests/async_http_test.cpp:44 [UTF8-LF sha256=1d3dc679c85dc177b7d255ad8a46eaa2d38f022f3f248d8c0720433aad50a9de; anchor=Reproduce installed SDK3.12.1]`. - Test-code evidence: `tests/ethernet_live_tool_test.mjs:17 [UTF8-LF sha256=6a0c0fcb764500ba41a918169306e417cbb94548a872dcccdbc424237bbcf9d9; anchor=for(const scenario of []`. - Scoped advantage: One native lwIP API/update owner across Wi-Fi and optional W5500, with bounded dispatch and truthful readiness. - Remaining: Actual W5500 local POST OTA, auth/abort/concurrent-client behavior and separate reboot/running-image verification remain open. Host fixtures and SDK build do not establish live OTA or interrupted-power safety. @@ -4234,12 +4748,12 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `c2941d73d383ce9e5059d8b01b90063d0c41e58d52302541d0dcc3f1e3d719dd`. -- Target: `firmware/esp/src/AsyncHttpService.h:128 [UTF8-LF sha256=50a432a62e484ae5f3bf59ffb5a9642ef66d7f07a5743402accb664056840d02; anchor=AsyncURIMatcher::exact(path)]`. +- Target: `firmware/esp/src/AsyncHttpService.h:128 [UTF8-LF sha256=646e7e4df17e61feb4db3ce3d19b5f9e48329e16d6c190724f4d6f5621fdc499; anchor=AsyncURIMatcher::exact(path)]`. - Target: `firmware/esp/src/LoopMessages.h:110 [UTF8-LF sha256=76b28eea3997400953a97ad530fc7d58d85f54af6a128fbbadeb06439263bf89; anchor=Message *items_=nullptr;]`. -- Target: `firmware/esp/application.ino:5052 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=loopMessages.begin(); // PSRAM-backed S3 queue]`. -- Target: `firmware/esp/src/MaintenanceRoutes.h:159 [UTF8-LF sha256=3e2ca1562541eea7218a6740c0ac9d2eba319072674fc0e0a3e86110a292adfc; anchor=internal_heap_largest_block_bytes]`. +- Target: `firmware/esp/application.ino:5228 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=loopMessages.begin(); // PSRAM-backed S3 queue]`. +- Target: `firmware/esp/src/MaintenanceRoutes.h:163 [UTF8-LF sha256=cc32c9d0b953c94e9a7bc8677cf171ea1a7695edc373e7f6911667db21f54abc; anchor=internal_heap_largest_block_bytes]`. - Target: `tools/update-firmware.mjs:155 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export function checkWebSocket(]`. -- Target: `firmware/esp/application.ino:2810 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=void onWebSocketEvent(]`. +- Target: `firmware/esp/application.ino:2908 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=void onWebSocketEvent(]`. - Target: `firmware/esp32s3/src/EthernetPort.h:63 [UTF8-LF sha256=9504a3f71b4c0998277764d6c5f39f66e7b8c3b49cfb294fb5e5869138e1a930; anchor=SPIClass spi_{HSPI};]`. - Retained: Complete original implementation read through EOF. Retains shared asynchronous server setup, /ws attachment, connected-client limits and per-client message dispatch. The actual AsyncTCP listener uses native lwIP interfaces, so Wi-Fi and W5500 reach identical routes. Existing Pakar bounded loop-owned dispatch and access ownership replace unsafe donor callback behavior. Reread for the exact-image S3 panic in WebAccess::bindSocket: reuse donor onWebSocketEvent's client->remoteIP(), passing its copied address into admission. Installed SDK _newClient transfers request->clientRelease() before CONNECT, leaving request headers valid but its TCP client null. The regression fixture explicitly releases that request client and checks retained peer address/version; no auth, origin or release-policy bypass. - Retained: Updater WebSocket preflight now adapts actual desktop main.rs::ws_worker read-only status acquisition: exactly one masked {type:status} is sent only after validating101/Accept/Connection:Upgrade and the actual pinned TCP peer. Only exact-board/boot recognized status-rgb/update-progress events may be skipped; neither renews the bounded full-status deadline nor satisfies readiness. The complete status must still pass boardPeer identity/capability checks. Separate measured connect/upgrade and full-status stages allow bounded cloud contention without removing the preflight or replaying any action. @@ -4252,9 +4766,9 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Implicit expectation that the first WebSocket text frame is an unsolicited full status`. Disposition: **replaced**. Reason: The integrated board/native worker supports explicit read-only status requests and can publish independent compact RGB/update events first. The updater now requests one status without credentials/control writes, validates every optional event identity before ignoring it, and refuses completion from partial/foreign/malformed frames. Both handshake and full-status waits remain finite and separately measured; retries of firmware or control messages are not added. - Test-code evidence: `tests/led_scheduler_test.cpp:32 [UTF8-LF sha256=be78fb8c6ed96fa86b475f2ef31540213057a3d3c83b1d6e5589f8187fc37084; anchor=S3 queue must not retain internal bulk storage]`. - Test-code evidence: `tests/ble_storage_stubs/esp_heap_caps.h:18 [UTF8-LF sha256=f6ff5f7843125cd7e780df39fd9d31006af9ef23070641ec580288235750365c; anchor=assert(capabilities==(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT))]`. -- Test-code evidence: `tests/update_tool_test.mjs:243 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=Exactly one read-only request, no replay]`. -- Test-code evidence: `tests/update_tool_test.mjs:254 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=Partial events never satisfy readiness or trigger request replay]`. -- Test-code evidence: `tests/async_http_test.cpp:43 [UTF8-LF sha256=aa31ba27f15b74ba5cd1f61133bc261776c09637b22a4f7d6365b9208867fc97; anchor=Reproduce installed SDK3.12.1]`. +- Test-code evidence: `tests/update_tool_test.mjs:243 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=Exactly one read-only request, no replay]`. +- Test-code evidence: `tests/update_tool_test.mjs:254 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=Partial events never satisfy readiness or trigger request replay]`. +- Test-code evidence: `tests/async_http_test.cpp:44 [UTF8-LF sha256=1d3dc679c85dc177b7d255ad8a46eaa2d38f022f3f248d8c0720433aad50a9de; anchor=Reproduce installed SDK3.12.1]`. - Test-code evidence: `tests/ethernet_live_tool_test.mjs:17 [UTF8-LF sha256=6a0c0fcb764500ba41a918169306e417cbb94548a872dcccdbc424237bbcf9d9; anchor=for(const scenario of []`. - Scoped advantage: One native lwIP API/update owner across Wi-Fi and optional W5500, with bounded dispatch and truthful readiness. - Remaining: Physical W5500 HTTP/WebSocket, malformed frames, client-limit, reconnect/revocation and concurrent LED/OTA soak remain open. Native ETH driver ownership/recovery is a Pakar/SDK addition, not a feature attributed to the PU donor. @@ -4264,11 +4778,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `db79b8de84d8f874df348b0d68b1d2056f8945ad742f172be99ddb9b1d94e218`. -- Target: `firmware/esp/src/AsyncHttpService.h:107 [UTF8-LF sha256=50a432a62e484ae5f3bf59ffb5a9642ef66d7f07a5743402accb664056840d02; anchor=explicit AsyncHttpService(uint16_t port) : native_(port) {}]`. -- Target: `firmware/esp/application.ino:190 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=AsyncWebSocket webSocket("/ws");]`. +- Target: `firmware/esp/src/AsyncHttpService.h:107 [UTF8-LF sha256=646e7e4df17e61feb4db3ce3d19b5f9e48329e16d6c190724f4d6f5621fdc499; anchor=explicit AsyncHttpService(uint16_t port) : native_(port) {}]`. +- Target: `firmware/esp/application.ino:205 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=AsyncWebSocket webSocket("/ws");]`. - Retained: Complete original header read through EOF. Shared HTTP80 and /ws WebSocket composition are retained in the actual Pakar server; the native Ethernet netif uses that same existing listener, API routes and authentication rather than new WIZnet sockets. - Omitted/replaced code: `Public donor global server/ws symbols and InitWebServer declaration`. Disposition: **replaced**. Reason: The integrated project already owns one AsyncHttpService facade and one AsyncWebSocket. Hardware-affecting callbacks defer into the loop owner using weak request lifetimes; duplicate globals/init would introduce divergent lifecycles, auth and connection owners. -- Test-code evidence: `tests/async_http_test.cpp:43 [UTF8-LF sha256=aa31ba27f15b74ba5cd1f61133bc261776c09637b22a4f7d6365b9208867fc97; anchor=Reproduce installed SDK3.12.1]`. +- Test-code evidence: `tests/async_http_test.cpp:44 [UTF8-LF sha256=1d3dc679c85dc177b7d255ad8a46eaa2d38f022f3f248d8c0720433aad50a9de; anchor=Reproduce installed SDK3.12.1]`. - Test-code evidence: `tests/ethernet_live_tool_test.mjs:17 [UTF8-LF sha256=6a0c0fcb764500ba41a918169306e417cbb94548a872dcccdbc424237bbcf9d9; anchor=for(const scenario of []`. - Scoped advantage: One native lwIP API/update owner across Wi-Fi and optional W5500, with bounded dispatch and truthful readiness. - Remaining: Real Ethernet HTTP/WebSocket serving and multi-client reconnect/access revocation remain unverified. The HTTP acceptance probe explicitly does not claim WebSocket or multicast acceptance. @@ -4277,44 +4791,46 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **scoped-improvement**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `7009bb58fb5bc686476c6a533284f2e62564442e35c4621922c19f145ce4c491`. -- Target: `firmware/esp/src/WebAccess.h:31 [UTF8-LF sha256=314ca16a853e550f62f71211c3607e1c27d5c9c488484f7168eb27ec85e45ca2; anchor=class WebAccess]`. +- Target: `firmware/esp/src/WebAccess.h:31 [UTF8-LF sha256=774b524f4cd81c003e4139d0bb6cf3353d987674bbfb49d7a1b989ee2a232294; anchor=class WebAccess]`. - Target: `firmware/esp/src/AccessConfig.cpp:67 [UTF8-LF sha256=aba76ed621e1e107ecea9a44ab32cb077fa7d08c0422b3631b3afeadc4e8d198; anchor=bool AccessConfig::configure(]`. -- Target: `desktop/src/ui/connection_controls.rs:209 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=if authentication == Some(true)]`. -- Target: `firmware/esp/src/WebUi.h:3208 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function presentDeviceAccess(info)]`. +- Target: `desktop/src/ui/connection_controls.rs:213 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=if authentication == Some(true)]`. +- Target: `firmware/esp/src/WebUi.h:3626 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function presentDeviceAccess(info)]`. - Retained: Complete exact donor source read and live SHA-256 matched the inventoried revision. Retained middleware authorization, public login, credential validation and cookie/session admission concepts in the actual shared WebAccess implementation. Added saved explicit authentication opt-in, default-off Web/native behavior, verified atomic storage migration, bounded session/WS ownership, rate limiting, origin and peer/boot fencing. - Omitted/replaced code: `complexAuth hardcoded user/password; doLogin password/token response and diagnostic dump; /pass PU access-level commands`. Disposition: **replaced**. Reason: Factory authentication is not an opt-in. Password/token dumps disclose secrets, and the PU access-level business commands do not describe this LED board. Use explicit saved access settings and non-disclosing session routes instead. - Omitted/replaced code: `Duplicated run implementation, placeholder AuthenticationHandler/getAuthToken/session access-level skeleton and role=staff sample`. Disposition: **replaced**. Reason: The donor skeleton has unresolved identifiers and no complete role-based authorization contract. Imported the applicable route/session behavior into compiling middleware rather than enabling an incomplete authorization example. No implemented Pakar role support is claimed. - Test-code evidence: `tests/access_config_test.cpp:9 [UTF8-LF sha256=335735974ecba358ec482cd949e8132fd2cca17f91ad43baa599cf0d36b08042; anchor=assert(!config.enabled())]`. -- Test-code evidence: `tests/web_access_fence_test.cpp:249 [UTF8-LF sha256=4bf15126c6e9ba945863583757fc8dbe521bddc850487cad08db6b80e7561743; anchor=// Auth-off does not mean loss]`. +- Test-code evidence: `tests/web_access_fence_test.cpp:259 [UTF8-LF sha256=a6d3b4d5a9fd3703c6265d093acaa21bd2cb17a30cda92ba9b4e18848e322a72; anchor=// Auth-off does not mean loss]`. - Test-code evidence: `tests/web_access_test.mjs:99 [UTF8-LF sha256=82a435e423c7730157bbe004c23aaf7454b4155cee3e196d8debf688d32e2385; anchor=authEnabled=false;signedIn=false;]`. -- Test-code evidence: `desktop/src/ui/connection_controls.rs:337 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=for authentication in [None, Some(false), Some(true)]]`. +- Test-code evidence: `desktop/src/ui/connection_controls.rs:342 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=for authentication in [None, Some(false), Some(true)]]`. - Scoped advantage: Executable auth-off/opt-in persistence and conditional Web/native credential UI, without weakening corruption recovery, origin or peer fencing. ### PU850-ESP: `LocalLib/Authentication.h` Verdict: **scoped-improvement**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d63933cf51fb09ef2ce09052fd616b8d62ca422d36c713d7705b9fb8717f287e`. -- Target: `firmware/esp/src/WebAccess.h:31 [UTF8-LF sha256=314ca16a853e550f62f71211c3607e1c27d5c9c488484f7168eb27ec85e45ca2; anchor=class WebAccess]`. +- Target: `firmware/esp/src/WebAccess.h:31 [UTF8-LF sha256=774b524f4cd81c003e4139d0bb6cf3353d987674bbfb49d7a1b989ee2a232294; anchor=class WebAccess]`. - Target: `firmware/esp/src/AccessConfig.cpp:67 [UTF8-LF sha256=aba76ed621e1e107ecea9a44ab32cb077fa7d08c0422b3631b3afeadc4e8d198; anchor=bool AccessConfig::configure(]`. -- Target: `desktop/src/ui/connection_controls.rs:209 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=if authentication == Some(true)]`. -- Target: `firmware/esp/src/WebUi.h:3208 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor=function presentDeviceAccess(info)]`. +- Target: `desktop/src/ui/connection_controls.rs:213 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=if authentication == Some(true)]`. +- Target: `firmware/esp/src/WebUi.h:3626 [UTF8-LF sha256=e81644ad10bac36fe1c35e07af8c7fe4b6537f9f4b48b2c82aaac682de97ea7c; anchor=function presentDeviceAccess(info)]`. - Retained: Complete exact donor source read and live SHA-256 matched the inventoried revision. Retained middleware authorization, public login, credential validation and cookie/session admission concepts in the actual shared WebAccess implementation. Added saved explicit authentication opt-in, default-off Web/native behavior, verified atomic storage migration, bounded session/WS ownership, rate limiting, origin and peer/boot fencing. - Omitted/replaced code: `complexAuth hardcoded user/password; doLogin password/token response and diagnostic dump; /pass PU access-level commands`. Disposition: **replaced**. Reason: Factory authentication is not an opt-in. Password/token dumps disclose secrets, and the PU access-level business commands do not describe this LED board. Use explicit saved access settings and non-disclosing session routes instead. - Omitted/replaced code: `Duplicated run implementation, placeholder AuthenticationHandler/getAuthToken/session access-level skeleton and role=staff sample`. Disposition: **replaced**. Reason: The donor skeleton has unresolved identifiers and no complete role-based authorization contract. Imported the applicable route/session behavior into compiling middleware rather than enabling an incomplete authorization example. No implemented Pakar role support is claimed. - Test-code evidence: `tests/access_config_test.cpp:9 [UTF8-LF sha256=335735974ecba358ec482cd949e8132fd2cca17f91ad43baa599cf0d36b08042; anchor=assert(!config.enabled())]`. -- Test-code evidence: `tests/web_access_fence_test.cpp:249 [UTF8-LF sha256=4bf15126c6e9ba945863583757fc8dbe521bddc850487cad08db6b80e7561743; anchor=// Auth-off does not mean loss]`. +- Test-code evidence: `tests/web_access_fence_test.cpp:259 [UTF8-LF sha256=a6d3b4d5a9fd3703c6265d093acaa21bd2cb17a30cda92ba9b4e18848e322a72; anchor=// Auth-off does not mean loss]`. - Test-code evidence: `tests/web_access_test.mjs:99 [UTF8-LF sha256=82a435e423c7730157bbe004c23aaf7454b4155cee3e196d8debf688d32e2385; anchor=authEnabled=false;signedIn=false;]`. -- Test-code evidence: `desktop/src/ui/connection_controls.rs:337 [UTF8-LF sha256=2ac32145d994a83c2a2ce5857df5ba0b8968ecb6784170ab43fa2138f0af5917; anchor=for authentication in [None, Some(false), Some(true)]]`. +- Test-code evidence: `desktop/src/ui/connection_controls.rs:342 [UTF8-LF sha256=02bbb46d280c606868f0f18220cb092c0dca2e07f9b06aa31bee1e4f4625ec65; anchor=for authentication in [None, Some(false), Some(true)]]`. - Scoped advantage: Executable auth-off/opt-in persistence and conditional Web/native credential UI, without weakening corruption recovery, origin or peer fencing. ### PU850-ESP: `LocalLib/Commands.cpp` Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `5181d7cc83efe2416a3a198ff88aad2aeffc8871ee2ef7a2a1ffdad51d31b88e`. -- Target: `firmware/esp/src/ShellService.h:72 [UTF8-LF sha256=897ac8e49f95e1e2ebc8b59dbd1a76148d1fcaaecf609cf33d99d067fc849a0c; anchor=bool shellSet(]`. -- Target: `firmware/esp/src/ShellTuiBinding.h:28 [UTF8-LF sha256=6f60f8f299f835d901e1ba510bfa13eadb7a3ed31c68c602e3b548fe780a6a2b; anchor=shellGet(property)]`. +- Target: `firmware/esp/src/ShellService.h:118 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=bool shellSet(]`. +- Target: `firmware/esp/src/ShellTuiBinding.h:58 [UTF8-LF sha256=5c389a6579d59cadf6db2e921de6094652977811c3beb0ca118b1bf3d778561c; anchor=shellGet(property)]`. - Target: `firmware/esp/src/ShellSettings.h:20 [UTF8-LF sha256=9e73a173b820cf54b70e4fc6506bccbb67fee5e4ab600fc9660503d05c491417; anchor=bool configure(]`. -- Target: `docs/PU850-PARITY.md:98 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Fresh runtime and shell section review, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:98 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Fresh runtime and shell section review, 2026-10-19]`. +- Target: `firmware/esp/src/ShellPixelCommand.h:14 [UTF8-LF sha256=a5a9f5a43035578d904c547848429e71acd68867418c36f7e5fb1d6375ad32e6; anchor=template]`. +- Target: `firmware/esp/src/ShellNames.h:12 [UTF8-LF sha256=d5900e6687a9930fc4fb173c5be73d7f976040eb99a7a39d54e0015f421859e7; anchor=inline bool sameName]`. - Retained: DeviceParameter name/type/read-only/get/set design is adapted to ShellProperty descriptors for hostname/label, network mode/DHCP/static addressing, station SSID/write-only password, MQTT/WebSocket endpoints, telemetry interval, shell settings and LED mode/brightness/effect. - Retained: Read-only observations expose version, boot identity, uptime, heap, AVR presence, display ACK state, firmware phase and actual LED backend/RMT/DMA capability; no shell-specific LED engine or second configuration store is introduced. - Retained: Useful echo/status/connect behavior maps to echo/status and queued network reconnect; typed JSON builds route device/cell/bulk/pixel/audio/display/firmware actions through existing validators and maintenance owners instead of PU callback addresses. @@ -4324,10 +4840,12 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Direct wifi_station/softap config mutation, AssignResult into the global command buffer`. Disposition: **replaced**. Reason: Shared typed JSON configuration owners validate and persist updates without truncating into the current command buffer or mutating ESP8266-only SDK structures. The generic descriptor/read-only mechanism itself is retained. - Omitted/replaced code: `AP_SSID/AP_Password descriptors and legacy save/restore aliases`. Disposition: **pending**. Reason: AP credential editing and reviewed restore semantics remain useful gaps. Actual Firmware_Hash/Build_Date descriptor/getter code now serves read-only firmware.hash/build.date with focused production-prefix fixtures; this is SDK sketch MD5/compiler time, not full-image integrity or installation time. - Omitted/replaced code: `Commented AP_IP/AP_Gateway/AP_Subnet parameter entries`. Disposition: **pending**. Reason: The donor leaves these entries commented out. Pakar station static-address fields are implemented, but independent configurable AP address/gateway/subnet via the shell is not claimed complete. -- Test-code evidence: `tests/shell_metadata_test.mjs:41 [UTF8-LF sha256=20e80f14d1cfb22f4b48e7d83496dc57e2a97dc9f737c1aac35d91b0bcafbdd5; anchor=Actual read-only firmware hash/build date getters]`. +- Test-code evidence: `tests/shell_metadata_test.mjs:11 [UTF8-LF sha256=a2d96c172d01d99f67fee1a7da1b341a2583cbf1c7133bc4308d3b8dfc07f01a; anchor=const start=source.indexOf]`. - Test-code evidence: `tests/shell_wire_test.cpp:29 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=4294967296]`. -- Test-code evidence: `tests/shell_tui_test.cpp:35 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=Readonly never emits Set.]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:71 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=!editor.remember(true)]`. +- Test-code evidence: `tests/shell_tui_test.cpp:41 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=Readonly never emits Set.]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:88 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=!editor.remember(true)]`. +- Test-code evidence: `tests/shell_pixel_test.cpp:6 [UTF8-LF sha256=f914cdb8df67ea915d286187b89208be931cfde70cac255a10b30e0d63b05690; anchor=int main()]`. +- Test-code evidence: `tests/shell_metadata_test.mjs:8 [UTF8-LF sha256=a2d96c172d01d99f67fee1a7da1b341a2583cbf1c7133bc4308d3b8dfc07f01a; anchor=const tuiKeys=]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -4337,10 +4855,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `48c0deb76d6105090fe1d58a2449427681a88775b1e63ddbb1b28ecff6341961`. -- Target: `firmware/esp/src/ShellService.h:72 [UTF8-LF sha256=897ac8e49f95e1e2ebc8b59dbd1a76148d1fcaaecf609cf33d99d067fc849a0c; anchor=bool shellSet(]`. -- Target: `firmware/esp/src/ShellTuiBinding.h:28 [UTF8-LF sha256=6f60f8f299f835d901e1ba510bfa13eadb7a3ed31c68c602e3b548fe780a6a2b; anchor=shellGet(property)]`. +- Target: `firmware/esp/src/ShellService.h:118 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=bool shellSet(]`. +- Target: `firmware/esp/src/ShellTuiBinding.h:58 [UTF8-LF sha256=5c389a6579d59cadf6db2e921de6094652977811c3beb0ca118b1bf3d778561c; anchor=shellGet(property)]`. - Target: `firmware/esp/src/ShellSettings.h:20 [UTF8-LF sha256=9e73a173b820cf54b70e4fc6506bccbb67fee5e4ab600fc9660503d05c491417; anchor=bool configure(]`. -- Target: `docs/PU850-PARITY.md:98 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Fresh runtime and shell section review, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:98 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Fresh runtime and shell section review, 2026-10-19]`. - Retained: DeviceParameter name/type/read-only/get/set design is adapted to ShellProperty descriptors for hostname/label, network mode/DHCP/static addressing, station SSID/write-only password, MQTT/WebSocket endpoints, telemetry interval, shell settings and LED mode/brightness/effect. - Retained: Read-only observations expose version, boot identity, uptime, heap, AVR presence, display ACK state, firmware phase and actual LED backend/RMT/DMA capability; no shell-specific LED engine or second configuration store is introduced. - Retained: Useful echo/status/connect behavior maps to echo/status and queued network reconnect; typed JSON builds route device/cell/bulk/pixel/audio/display/firmware actions through existing validators and maintenance owners instead of PU callback addresses. @@ -4350,10 +4868,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Direct wifi_station/softap config mutation, AssignResult into the global command buffer`. Disposition: **replaced**. Reason: Shared typed JSON configuration owners validate and persist updates without truncating into the current command buffer or mutating ESP8266-only SDK structures. The generic descriptor/read-only mechanism itself is retained. - Omitted/replaced code: `AP_SSID/AP_Password descriptors and legacy save/restore aliases`. Disposition: **pending**. Reason: AP credential editing and reviewed restore semantics remain useful gaps. Actual Firmware_Hash/Build_Date descriptor/getter code now serves read-only firmware.hash/build.date with focused production-prefix fixtures; this is SDK sketch MD5/compiler time, not full-image integrity or installation time. - Omitted/replaced code: `Commented AP_IP/AP_Gateway/AP_Subnet parameter entries`. Disposition: **pending**. Reason: The donor leaves these entries commented out. Pakar station static-address fields are implemented, but independent configurable AP address/gateway/subnet via the shell is not claimed complete. -- Test-code evidence: `tests/shell_metadata_test.mjs:41 [UTF8-LF sha256=20e80f14d1cfb22f4b48e7d83496dc57e2a97dc9f737c1aac35d91b0bcafbdd5; anchor=Actual read-only firmware hash/build date getters]`. +- Test-code evidence: `tests/shell_metadata_test.mjs:11 [UTF8-LF sha256=a2d96c172d01d99f67fee1a7da1b341a2583cbf1c7133bc4308d3b8dfc07f01a; anchor=const start=source.indexOf]`. - Test-code evidence: `tests/shell_wire_test.cpp:29 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=4294967296]`. -- Test-code evidence: `tests/shell_tui_test.cpp:35 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=Readonly never emits Set.]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:71 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=!editor.remember(true)]`. +- Test-code evidence: `tests/shell_tui_test.cpp:41 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=Readonly never emits Set.]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:88 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=!editor.remember(true)]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -4363,7 +4881,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `be001f01f3467950ec27b578a16c391d4d680e1bb562e3d8c06fb422d28cc5c9`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: active crash_ets_uart_putc1; complete commented custom_crash_callback timestamp/reset/register offsets/counter/cursor/stack heuristic - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `commented crash persistence/stack-capture sketch`. Disposition: **pending**. Reason: No active donor persistence implementation exists. Design bounded secret-safe platform capture/rotation and fault proof; keep S3 SDK panic/reset evidence rather than importing ESP8266 suppression. @@ -4373,7 +4891,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d958445204e65686693ab766a3b090095585ed538c374139b7baf5f61c91785a`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: pragma/include; NO_CUT_HERE; crash printer declaration; commented custom callback declaration - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic durable crash capture contract`. Disposition: **pending**. Reason: Do not transplant ESP8266 callback structs/cont registers into S3; safe storage/rotation and fault acceptance remain open. @@ -4401,7 +4919,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `readFlashRange failure returning0 from an in-flight asynchronous fixed-length response`. Disposition: **replaced**. Reason: Zero is SDK end-of-file, not a transient flash error. A bounded retry/read-window plus source-invalid transport failure preserves advertised length/integrity semantics without advancing offsets, silently truncating, retaining dangling requests or retrying any mutation. Raw app-image authorization/range/ETag/HEAD behavior is unchanged; full-flash secrets remain disabled. - Test-code evidence: `tests/firmware_read_response_test.cpp:41 [UTF8-LF sha256=4e2dfe3020a82e402654d607dfb9fb73927a10735f47c5cdb2d09a3f93bea1a0; anchor=real SDK sets RESPONSE_FAILED + closes, never EOF]`. - Test-code evidence: `tests/firmware_range_test.cpp:7 [UTF8-LF sha256=96a79254872cd829c6e23a240225063503d0286e9fe1a477dd48aac5e05a6ed5; anchor=assert(parseRange("bytes=0-9"]`. -- Test-code evidence: `tests/s3_platform_wiring_test.mjs:44 [UTF8-LF sha256=2f224ecd6b8eade6e985bf64244690a8a93347fe6e95ab0b4646edfaa823412a; anchor=assert.match(transfer, /esp_partition_read]`. +- Test-code evidence: `tests/s3_platform_wiring_test.mjs:44 [UTF8-LF sha256=778c0fee4df01440d52814b64363f0fba85d235edc457b4bae2739e94cf4e662; anchor=assert.match(transfer, /esp_partition_read]`. - Test-code evidence: `tests/ethernet_live_tool_test.mjs:17 [UTF8-LF sha256=6a0c0fcb764500ba41a918169306e417cbb94548a872dcccdbc424237bbcf9d9; anchor=for(const scenario of []`. - Test-code evidence: `desktop/src/bridge_transfer.rs:275 [UTF8-LF sha256=fbb13202d86296f1766d0e20bcd9e4a520a8b13d4988890e5a5273a20a5469fe; anchor=fn bridge_transfer_real_binary_range_and_multipart_are_not_json_reparsed]`. - Remaining: Real HTTP GET/HEAD/Range/auth/ETag checks against the integrated S3 and ESP8266 firmware remain separate from source/host-parser proof; verify exported application bytes against the running partition and protect credentials. Physical OTA/AVR flashing/power-loss/recovery acceptance remains open. @@ -4425,7 +4943,7 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 - Target: `firmware/esp/src/SystemPlatform.h:24 [UTF8-LF sha256=04bdc4f87784f3faa910c5219b8cb261bbdc64bca4505f2ac24f577f961669aa; anchor=inline bool mountFilesystem()]`. - Target: `firmware/esp/src/FilesystemTelemetry.h:31 [UTF8-LF sha256=6e0cdf19a541d7b60eb682729f5cc4436cde5d8ce3dfd2322077708dee3f0d9a; anchor=class Observation]`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: entire commented initFS mount and Serial result logger - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `inactive mount/logger example`. Disposition: **replaced**. Reason: Actual SystemPlatform disables automatic format; FilesystemTelemetry reports mounted/unknown/error without protocol UART injection. Physical persistence/full-storage/power-loss acceptance stays open. @@ -4438,8 +4956,8 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `firmware/esp/src/MndpService.h:17 [UTF8-LF sha256=465067669cc66495299af3687dd91e78258ae3c4d01a65b7a15899c14c808537; anchor=class MndpService]`. - Target: `mobile/lib/discovery.dart:378 [UTF8-LF sha256=8cb3956f80a50c5be2c134829bc7d323a3f4c58fbbcf1ea5d5e1aaa10ac7fbba; anchor=DiscoveredDevice? parseMndp]`. - Target: `firmware/esp32s3/src/DiscoveryInterfacePolicy.h:12 [UTF8-LF sha256=94ead0d3e60e0aebe1a4bce7c18203cce57cfebacfbc2caff0fe3f6bb6b85814; anchor=struct DiscoveryInterfaces]`. -- Target: `firmware/esp/src/ConfigurationChanges.h:23 [UTF8-LF sha256=e097d49a720476c2256a2fee7f2aff25c0425e04525fcc7525cf27e633b4821f; anchor=class Journal]`. -- Target: `firmware/esp/application.ino:2524 [UTF8-LF sha256=2063aba68b98cb6dc166be6accd7dccf9e5e35b7ebcac994f87d7590e56bfdf0; anchor=void serviceConfigurationChanges()]`. +- Target: `firmware/esp/src/ConfigurationChanges.h:24 [UTF8-LF sha256=067b5a6943105d2d895ec939447dcefa38ff14370a13c1bbb33dc8ee7ad89393; anchor=class Journal]`. +- Target: `firmware/esp/application.ino:2561 [UTF8-LF sha256=9c01e9952d691509428c94a694864b4cda59bec94cfb3c43c4252fdf749824da; anchor=void serviceConfigurationChanges()]`. - Retained: Fully read header/implementation. Actual port 5678/group 224.0.0.88 and big-endian TLV type/length layout adapted into bounded packets; MAC, hostname, firmware version, platform, board, uptime, stable board ID and IPv4 metadata retained. - Retained: Integrated station lifecycle starts/stops MNDP alongside existing discovery, announces at 30 seconds and accepts only a rate-limited four-zero-byte query. Android scans/decodes defensive bounded TLVs without sending credentials. Packet overflow prevents any partial advertisement. - Retained: S3 enumeration now emits actual ready Ethernet/STA/AP MAC, IP, mask-derived reply selection and interface kind, with bounded membership recovery, while preserving donor TLV/rate/packet bounds. Ethernet AP/local discovery is not advertised as Internet proof. @@ -4475,7 +4993,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `firmware/esp32s3/src/NbnsWire.h:26 [UTF8-LF sha256=5ecc7462855057585a28b732216798637158e5b830863b546490859ab8110699; anchor=inline size_t answer(]`. - Target: `firmware/esp32s3/src/NbnsService.h:14 [UTF8-LF sha256=01d4a3c989bb3adaed24b13fb9e7923b99756bcb117e030428a8efab46c01d03; anchor=class NbnsService]`. -- Target: `firmware/esp32s3/src/Discovery.h:42 [UTF8-LF sha256=df7e866c85b061a7b01babd6974ec37a71d25ae52e31c4e1c5ccedb327587154; anchor=bool netbiosReady() const]`. +- Target: `firmware/esp32s3/src/Discovery.h:42 [UTF8-LF sha256=d2cd172d2934642d6255106edb2a417a5bf128ff021dd353ad4de57262277cd0; anchor=bool netbiosReady() const]`. - Target: `firmware/esp32s3/src/Lldp.h:46 [UTF8-LF sha256=1c485dc2ee5fcaceefc2ac815a022a7fab4e5aa226d36256734ffb293c199060; anchor=inline bool parse(]`. - Target: `firmware/esp32s3/src/EthernetLldp.cpp:136 [UTF8-LF sha256=753dd01083136f14e44d862fa883c916bbf6c971a64b8762dc1cc14c207c7e2c; anchor=void serviceEthernetLldp(]`. - Target: `tools/check-router-upnp.ps1:60 [UTF8-LF sha256=91700b6cbeb7717b9d72b07247f2044f7a09cb8450753b3e49429046c61d5a39; anchor=function Invoke-DiscoveryProbe(]`. @@ -4496,7 +5014,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `firmware/esp32s3/src/NbnsWire.h:26 [UTF8-LF sha256=5ecc7462855057585a28b732216798637158e5b830863b546490859ab8110699; anchor=inline size_t answer(]`. - Target: `firmware/esp32s3/src/NbnsService.h:14 [UTF8-LF sha256=01d4a3c989bb3adaed24b13fb9e7923b99756bcb117e030428a8efab46c01d03; anchor=class NbnsService]`. -- Target: `firmware/esp32s3/src/Discovery.h:42 [UTF8-LF sha256=df7e866c85b061a7b01babd6974ec37a71d25ae52e31c4e1c5ccedb327587154; anchor=bool netbiosReady() const]`. +- Target: `firmware/esp32s3/src/Discovery.h:42 [UTF8-LF sha256=d2cd172d2934642d6255106edb2a417a5bf128ff021dd353ad4de57262277cd0; anchor=bool netbiosReady() const]`. - Target: `firmware/esp32s3/src/Lldp.h:46 [UTF8-LF sha256=1c485dc2ee5fcaceefc2ac815a022a7fab4e5aa226d36256734ffb293c199060; anchor=inline bool parse(]`. - Target: `firmware/esp32s3/src/EthernetLldp.cpp:136 [UTF8-LF sha256=753dd01083136f14e44d862fa883c916bbf6c971a64b8762dc1cc14c207c7e2c; anchor=void serviceEthernetLldp(]`. - Target: `tools/check-router-upnp.ps1:60 [UTF8-LF sha256=91700b6cbeb7717b9d72b07247f2044f7a09cb8450753b3e49429046c61d5a39; anchor=function Invoke-DiscoveryProbe(]`. @@ -4515,7 +5033,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `9cda4aa1a1d02d3a7f8042173e049d326788952b7a05438a57b91d6e7d381303`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: UnPack32 four MSB-first shifts; eExpandDateTime/eCompressDateTime; CompressDate/ShamsiU16toU8/GregorianU16toU8 - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic compressed Gregorian/Shamsi date/time and year offsets`. Disposition: **pending**. Reason: Preserve useful calendar capability with validated real consumers/ranges/months/tests, not shared mutable ePart/year globals or PU payload compatibility. @@ -4525,7 +5043,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `a5e504e4b33c8b1e26d41a7e5a8196fc1f7e7ae7a41be6209dc5617e484a83af`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: stdbool include and bool-to-boolean/true/false replacement macros - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `C++ boolean token replacement`. Disposition: **replaced**. Reason: The supported toolchains natively implement bool/true/false; global macro substitution can corrupt SDK/system headers. @@ -4534,18 +5052,25 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `9ca2a3d6be55cc5d0271d0e036e28fbc6a25ed71c276f53128262a03d30e9e52`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `firmware/esp/src/ShellRemoteQuery.h:11 [UTF8-LF sha256=d5c21db264e3bc5519ca63c0ff7568183dc5b321ce6a6842468052068449c0e4; anchor=class RemoteQuery]`. +- Target: `firmware/esp/src/ShellService.h:20 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void serviceShellRemoteQuery()]`. +- Target: `tools/udp-device-query.mjs:10 [UTF8-LF sha256=1d0c45a2f2f05a2564ed9a80d116fc52c4564e0e79b07cebb09ee8bd872e7e25; anchor=const nonce=]`. - Retained: Every section/function read through EOF: all UDP state/ports; initUdpDebug/UdpService/udpSend/handleUdpReceive/executeCommand; all ServiceEvent cases - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. -- Omitted/replaced code: `Generic bounded UDP diagnostic/network-event lifecycle`. Disposition: **pending**. Reason: File is disabled in donor sketch, input truncates at254 bytes, no auth/identity, and UdpService tests a function pointer. Define capability/ownership/fixtures before adding any listener or SDK event adapter. -- Remaining: File is disabled in donor sketch, input truncates at254 bytes, no auth/identity, and UdpService tests a function pointer. Define capability/ownership/fixtures before adding any listener or SDK event adapter. +- Retained: initUdpDebug/UdpService receive/reply lifecycle adapted in ShellRemoteQuery: explicit opt-in public read-only UDP, fixed port 9876, network-address rebinding, bounded datagrams, correlated unicast responses and bind backoff. +- Omitted/replaced code: `Generic bounded UDP diagnostic/network-event lifecycle`. Disposition: **replaced**. Reason: The disabled donor listener is now an opt-in public read-only query with correlation, bounded input/output, rate limiting, unicast response and lifecycle fixtures. Auth-enabled policy disables this UDP surface. Arbitrary command execution, UDP authentication/ownership and live LAN acceptance remain pending. +- Test-code evidence: `tests/shell_remote_query_test.cpp:17 [UTF8-LF sha256=5d3a8434ea40cc4d5458f739eb5c23524d5d586bda1af129859d0980accb16b3; anchor=int main()]`. +- Test-code evidence: `tests/udp_device_query_test.mjs:20 [UTF8-LF sha256=45cf835ed865cdcd02a6cb0458c07d17815b7b5b0299475e7a9e5d9d9c4d6f19; anchor=assert.equal(requests,1)]`. +- Remaining: Read-only UDP candidate requires actual LAN/AP/Ethernet tests; host socket fixtures do not establish board reachability. +- Remaining: Authenticated mutating UDP commands are not implemented: use existing protected Telnet/USB/HTTP/WebSocket routing; never copy donor unauthenticated dispatch. ### PU850-ESP: `LocalLib/Sessions.cpp` Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `6186f9861e8ee3dcb093f260e25311a1e3952b5c6c2a7fa764de05b8a45e7f55`. - Target: `libraries/BoardCore/src/AccessPolicy.h:39 [UTF8-LF sha256=3524ee6acd036d7ef30e266d55fd71045628abe2c57243383162dea8e90c55d0; anchor=class Sessions]`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: initSessions; addSession free-slot and unused-oldest scan; destroySession; validateSession; empty SessionCommand - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `SessionCommand authenticated protocol-origin revoke semantics`. Disposition: **pending**. Reason: Existing bounded 8-slot AccessPolicy imports the pool with expiry, unsigned elapsed time, strict hex and constant-work compare. Donor SessionCommand is empty; review actual UART-origin authority before implementing a new command. @@ -4557,7 +5082,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `1ab8ea8f32ce56d64b976eb1c6922fa5476a3fd0498526bbf453a4254726862c`. - Target: `libraries/BoardCore/src/AccessPolicy.h:39 [UTF8-LF sha256=3524ee6acd036d7ef30e266d55fd71045628abe2c57243383162dea8e90c55d0; anchor=class Sessions]`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: MAX_SESSIONS/TOKEN_LENGTH; raw token/accessLevel/lastActive/lastIP/isLoggedIn struct/global array; SessionCommand declaration - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `SessionCommand lifecycle declaration`. Disposition: **pending**. Reason: Existing encapsulated AccessPolicy/WebAccess owns roles/address/expiry/mutex admission; exact authenticated UART session lifecycle is still pending. @@ -4568,11 +5093,22 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `0113acbff973285f5af186a4857d94cc2df3c99813e0db4ba876f861e7c1083b`. -- Target: `firmware/esp/src/TelnetShell.h:95 [UTF8-LF sha256=20e75d8dd8f39bea93c0df3da5aff98635b857eefb55003cfebeabd4e5c92281; anchor=class LineEditor]`. -- Target: `firmware/esp/src/ShellProperties.h:15 [UTF8-LF sha256=7f5e741f7ff29d308da7d201a59b37f116a5b846ec7bcb157032477ca7f34816; anchor=static const ShellProperty shellProperties[]]`. +- Target: `firmware/esp/src/TelnetShell.h:139 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class LineEditor]`. +- Target: `firmware/esp/src/ShellProperties.h:16 [UTF8-LF sha256=7a6cb87cf78a769be09ac82a5d3d3b3912f485b36d98a6676b5320d8b54ed0fa; anchor=static const ShellProperty shellProperties[]]`. - Target: `firmware/esp/src/ShellWire.h:9 [UTF8-LF sha256=af3f23d908310dd349b0c90d1fc4385a212e6439b10c72f8dc2f37dace4b5216; anchor=class SerialChannel]`. -- Target: `firmware/esp/src/ShellTuiBinding.h:3 [UTF8-LF sha256=6f60f8f299f835d901e1ba510bfa13eadb7a3ed31c68c602e3b548fe780a6a2b; anchor=struct ShellTuiSnapshot]`. +- Target: `firmware/esp/src/ShellTuiBinding.h:3 [UTF8-LF sha256=5c389a6579d59cadf6db2e921de6094652977811c3beb0ca118b1bf3d778561c; anchor=struct ShellTuiSnapshot]`. - Target: `tools/device-shell.ps1:8 [UTF8-LF sha256=4e197e3502eb6ef40d2a3df7bdde97ed3c05140c97e06e3d07426232732a9c55; anchor=AutomaticCommandRetry=$false]`. +- Target: `firmware/esp/src/SshShellBinding.h:36 [UTF8-LF sha256=cc8826e98ce85dddfa6e3ed4a34854124f477fdf658f410b2f97be99e875891e; anchor=void serviceSshShell()]`. +- Target: `firmware/esp32s3/src/SshTransport.h:31 [UTF8-LF sha256=326288f1c74218a3dc68ed3f3561bcf7ca579eec97e771c7a8a6ffbbfe8b8301; anchor=class SshTransport {]`. +- Target: `firmware/esp32s3/src/ScpDownload.h:10 [UTF8-LF sha256=a5864de20e077f081c03ddce98e9a9dac3a78a46149b3618ae66f330a53aca8c; anchor=class ScpDownload {]`. +- Target: `firmware/esp/src/SshScpBinding.h:11 [UTF8-LF sha256=9acf2193fac080639fc5460273b16a3ebc5a1022fb4c95f67df8c0dab22d8e4a; anchor=void serviceSshScp()]`. +- Target: `firmware/esp32s3/src/ScpUpload.h:8 [UTF8-LF sha256=f1a8c0743f079973a4fdedee3ac4ad16b538e6a28253763280ecec6204a240b5; anchor=class ScpUpload]`. +- Target: `firmware/esp32s3/src/SftpSession.h:14 [UTF8-LF sha256=ff44da0e5871e592909952eaa54b1f563dbb4ec88a867b280089c11709a081d6; anchor=class SftpSession]`. +- Target: `firmware/esp32s3/src/FtpSession.h:10 [UTF8-LF sha256=0fe8968237417b2fb2a1fe40b0471bffb7c87d67c1114d5f16c04ba1c4f94dae; anchor=class FtpSession]`. +- Target: `firmware/esp/src/TransferFiles.h:7 [UTF8-LF sha256=dea201ffe11e3516975ef7b2c7850aac74940514eb8c05ee7b521ff6698ba18d; anchor=class TransferFiles]`. +- Target: `firmware/esp/src/FileTransferBinding.h:9 [UTF8-LF sha256=666f8822b937bcda527bbe9c121040e4ef2d32894e91057988450774f0d427b0; anchor=serviceFileTransferCleanup]`. +- Target: `firmware/esp/src/FtpBinding.h:4 [UTF8-LF sha256=ee60fbc7b27399818c2d46c5c7401d1688c7e595d2a5fd9b52d458e65902adfc; anchor=namespace DeviceFtp]`. +- Target: `firmware/esp/src/SshUploadBinding.h:12 [UTF8-LF sha256=563386de05c9b0190942e73c1eafdfa384d06438e69da5119834642cab357b77; anchor=void resetSshTransfer]`. - Retained: PU ShellSession transport/editor separation, insertion/shift/backspace/delete, CSI/SS3 arrows/home/end, Ctrl-C/Ctrl-L and split CR-LF/CR-NUL behavior are adapted in instance-owned TelnetParser/LineEditor. - Retained: Generic parameter lookup/read-only enforcement and dispatch now map to ShellProperty descriptors, typed shellSet and the existing Pakar command/configuration owners; the same descriptors feed the device TUI. - Retained: Session-owned prompt-safe watch summaries use redrawEvent without losing the draft/cursor. Utilities clear/cls, echo, hex and upper/noupper are present; bounded eight-entry history adds recall without retaining classified secrets. @@ -4580,27 +5116,38 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Raw ShellTransport_Serial/SerialShellSession and mutable global command/include Commands.cpp adapter`. Disposition: **replaced**. Reason: A separately boot-fenced COBS shell channel preserves USB recovery/protocol framing rather than mixing raw ASCII into the existing decoder. Instance-owned dispatch removes the donor global command/output pointer; host script never automatically replays uncertain input. - Omitted/replaced code: `NumberFromString permissive comma/minus parsing and unchecked signed multiply/add`. Disposition: **replaced**. Reason: Strict unsignedNumber validates full decimal input and overflow before conversion; command-specific signed display handling is explicit. Fixtures reject signs, commas, trailing junk and UINT32 overflow rather than silently normalizing a dangerous argument. - Omitted/replaced code: `ShellBroadcastLine unrestricted event broadcast`. Disposition: **replaced**. Reason: Opt-in per-session watch publishes changed bounded summaries with prompt erase/redraw and backpressure. This is not an ordered complete peripheral event stream; individual event delivery remains outside this shell slice. -- Omitted/replaced code: `upper/noupper applies to all donor input echo and prompt redraw`. Disposition: **pending**. Reason: Current session upper option changes echo command output only; the original wider input-display behavior is not claimed equivalent. +- Omitted/replaced code: `upper/noupper applies to all donor input echo and prompt redraw`. Disposition: **replaced**. Reason: Input echo, insertion, cursor redraw and history recall now uppercase only displayed bytes, preserving raw commands and secret suppression; portable_shell_test covers it. - Omitted/replaced code: `Donor printable-ASCII editor and richer Unicode terminals`. Disposition: **pending**. Reason: Arduino line editor/TUI deliberately accepts printable ASCII and sanitizes external controls. Persian/UTF-8 editing, bidi layout and font support are not supplied by this shell adaptation. - Test-code evidence: `tests/shell_service_test.mjs:18 [UTF8-LF sha256=287a22bebfcf0457e307416b1bb6524d71a28a8876646691b449df017b023be6; anchor=Actual shell fixture extraction markers changed]`. -- Test-code evidence: `tests/shell_service_test.cpp:132 [UTF8-LF sha256=f235dde1902ae6949f8a4460a03c55b99c8886adf6befc113af9f9ea6112294b; anchor=Accept crosses millis rollover.]`. -- Test-code evidence: `tests/shell_properties_test.cpp:4 [UTF8-LF sha256=0d2aa839f477d639f994c57cf5432a9d135a70cc869932f5d991bb6788b11ca6; anchor=int main()]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:88 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=editor.redrawEvent(]`. +- Test-code evidence: `tests/shell_service_test.cpp:137 [UTF8-LF sha256=076058d45758b87cf76c294e94827929dd74a7e7075cee533aa4ee7251f7e47b; anchor=Accept crosses millis rollover.]`. +- Test-code evidence: `tests/shell_properties_test.cpp:5 [UTF8-LF sha256=5e82c5c20b2b39fc1efe4def4c4362bded29a81e464ed23fb16b329e98ed037b; anchor=int main()]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:105 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=editor.redrawEvent(]`. - Test-code evidence: `tests/shell_wire_test.cpp:11 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=C::Duplicate]`. -- Test-code evidence: `tests/shell_tui_test.cpp:26 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=Unchanged Enter is not a write.]`. -- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=5eda930ea11c15531c71b593dc7fa7de7a15a9376e090185774cc9bbb2a78d85; anchor=-Concern device-shell -Gate]`. +- Test-code evidence: `tests/shell_tui_test.cpp:32 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=Unchanged Enter is not a write.]`. +- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=9fb4a11ea30f14cbf306683cdbf1fdfe6042b5a3ddcc13f4a87cfb9f69b0f075; anchor=-Concern device-shell -Gate]`. +- Test-code evidence: `tests/ssh_settings_test.cpp:17 [UTF8-LF sha256=44c6fda6dce749d7302d284c9f8ba1b9919b36fefd4e3e61fd00b665dfdf6ad2; anchor=int main()]`. +- Test-code evidence: `tests/ssh_policy_test.cpp:5 [UTF8-LF sha256=4d6e33842b9108763776e1aab04261235adc0abd2a70cbfeadcc43a7c470822e; anchor=int main()]`. +- Test-code evidence: `tests/ssh_scp_binding_test.cpp:21 [UTF8-LF sha256=b47ef508eb652a9099ab0f29e33da744e83d23f1b452cc1c195b4118a3081412; anchor=int main()]`. +- Test-code evidence: `tests/scp_download_test.cpp:10 [UTF8-LF sha256=49b22f7ce4a962b1d5fd677ac3bc37bbc6aac8f41a5f7ac4ebcf9790caa79dd8; anchor=int main(]`. +- Test-code evidence: `tests/scp_client_test.mjs:15 [UTF8-LF sha256=bec216abf19d74af291ecba65e496d689e8ccdeb4e4c12f0707fcea2389b6a1d; anchor=Real OpenSSH SCP client accepted]`. +- Test-code evidence: `tests/file_transfer_test.cpp:22 [UTF8-LF sha256=b59d00420cac2a5eec4c863cfc5d60e1a93d4b1b041b8f90e1b01250a157dd8e; anchor=int main(]`. +- Test-code evidence: `tests/file_transfer_client_test.mjs:18 [UTF8-LF sha256=254834d63c0db3ac14d83db5cdb16c1a6773a6a9df78f7d270c38bcdc0d67ede; anchor=Real OpenSSH clients:]`. +- Test-code evidence: `tests/ftp_binding_test.cpp:61 [UTF8-LF sha256=442d67f43bf96b8031b6966c89ac9f9edf98d66afda048179242c734de502b3b; anchor=Actual FTP socket binding:]`. +- Test-code evidence: `tests/ftp_stubs/sys/socket.h:3 [UTF8-LF sha256=a03b479d9c3b25a90b08c8580d4a54ced5415914bd3ce805ef43706efa7b7a45; anchor=MSG_DONTWAIT]`. +- Test-code evidence: `tools/test-ssh.ps1:52 [UTF8-LF sha256=c71f2dd61de2d59712f0ab8e8609219bfe3acf56c93700aea343b026da745798; anchor=Actual FTP socket binding fixture]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. +- Remaining: Issue #33: native SSH plus gated transactional SCP/SFTP and passive FTP share the public-file store and maintenance lease. Policy/NVS/socket-FS doubles and real OpenSSH clients over stdio are not an encrypted board login. Exact footprint/SDK evidence belongs in the receipt and SSH.md; actual login, identity persistence, memory/TLS/LED coexistence and separately authorized live transfers/deployment remain open. ### PU850-ESP: `LocalLib/Shell.h` Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `9f8e76509ad917d8e204a82d9495e376607a159547d4eee3de6760e6bd5d7da6`. -- Target: `firmware/esp/src/TelnetShell.h:95 [UTF8-LF sha256=20e75d8dd8f39bea93c0df3da5aff98635b857eefb55003cfebeabd4e5c92281; anchor=class LineEditor]`. -- Target: `firmware/esp/src/ShellProperties.h:15 [UTF8-LF sha256=7f5e741f7ff29d308da7d201a59b37f116a5b846ec7bcb157032477ca7f34816; anchor=static const ShellProperty shellProperties[]]`. +- Target: `firmware/esp/src/TelnetShell.h:139 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class LineEditor]`. +- Target: `firmware/esp/src/ShellProperties.h:16 [UTF8-LF sha256=7a6cb87cf78a769be09ac82a5d3d3b3912f485b36d98a6676b5320d8b54ed0fa; anchor=static const ShellProperty shellProperties[]]`. - Target: `firmware/esp/src/ShellWire.h:9 [UTF8-LF sha256=af3f23d908310dd349b0c90d1fc4385a212e6439b10c72f8dc2f37dace4b5216; anchor=class SerialChannel]`. -- Target: `firmware/esp/src/ShellTuiBinding.h:3 [UTF8-LF sha256=6f60f8f299f835d901e1ba510bfa13eadb7a3ed31c68c602e3b548fe780a6a2b; anchor=struct ShellTuiSnapshot]`. +- Target: `firmware/esp/src/ShellTuiBinding.h:3 [UTF8-LF sha256=5c389a6579d59cadf6db2e921de6094652977811c3beb0ca118b1bf3d778561c; anchor=struct ShellTuiSnapshot]`. - Target: `tools/device-shell.ps1:8 [UTF8-LF sha256=4e197e3502eb6ef40d2a3df7bdde97ed3c05140c97e06e3d07426232732a9c55; anchor=AutomaticCommandRetry=$false]`. - Retained: PU ShellSession transport/editor separation, insertion/shift/backspace/delete, CSI/SS3 arrows/home/end, Ctrl-C/Ctrl-L and split CR-LF/CR-NUL behavior are adapted in instance-owned TelnetParser/LineEditor. - Retained: Generic parameter lookup/read-only enforcement and dispatch now map to ShellProperty descriptors, typed shellSet and the existing Pakar command/configuration owners; the same descriptors feed the device TUI. @@ -4609,15 +5156,15 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Raw ShellTransport_Serial/SerialShellSession and mutable global command/include Commands.cpp adapter`. Disposition: **replaced**. Reason: A separately boot-fenced COBS shell channel preserves USB recovery/protocol framing rather than mixing raw ASCII into the existing decoder. Instance-owned dispatch removes the donor global command/output pointer; host script never automatically replays uncertain input. - Omitted/replaced code: `NumberFromString permissive comma/minus parsing and unchecked signed multiply/add`. Disposition: **replaced**. Reason: Strict unsignedNumber validates full decimal input and overflow before conversion; command-specific signed display handling is explicit. Fixtures reject signs, commas, trailing junk and UINT32 overflow rather than silently normalizing a dangerous argument. - Omitted/replaced code: `ShellBroadcastLine unrestricted event broadcast`. Disposition: **replaced**. Reason: Opt-in per-session watch publishes changed bounded summaries with prompt erase/redraw and backpressure. This is not an ordered complete peripheral event stream; individual event delivery remains outside this shell slice. -- Omitted/replaced code: `upper/noupper applies to all donor input echo and prompt redraw`. Disposition: **pending**. Reason: Current session upper option changes echo command output only; the original wider input-display behavior is not claimed equivalent. +- Omitted/replaced code: `upper/noupper applies to all donor input echo and prompt redraw`. Disposition: **replaced**. Reason: Input echo, insertion, cursor redraw and history recall now uppercase only displayed bytes, preserving raw commands and secret suppression; portable_shell_test covers it. - Omitted/replaced code: `Donor printable-ASCII editor and richer Unicode terminals`. Disposition: **pending**. Reason: Arduino line editor/TUI deliberately accepts printable ASCII and sanitizes external controls. Persian/UTF-8 editing, bidi layout and font support are not supplied by this shell adaptation. - Test-code evidence: `tests/shell_service_test.mjs:18 [UTF8-LF sha256=287a22bebfcf0457e307416b1bb6524d71a28a8876646691b449df017b023be6; anchor=Actual shell fixture extraction markers changed]`. -- Test-code evidence: `tests/shell_service_test.cpp:132 [UTF8-LF sha256=f235dde1902ae6949f8a4460a03c55b99c8886adf6befc113af9f9ea6112294b; anchor=Accept crosses millis rollover.]`. -- Test-code evidence: `tests/shell_properties_test.cpp:4 [UTF8-LF sha256=0d2aa839f477d639f994c57cf5432a9d135a70cc869932f5d991bb6788b11ca6; anchor=int main()]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:88 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=editor.redrawEvent(]`. +- Test-code evidence: `tests/shell_service_test.cpp:137 [UTF8-LF sha256=076058d45758b87cf76c294e94827929dd74a7e7075cee533aa4ee7251f7e47b; anchor=Accept crosses millis rollover.]`. +- Test-code evidence: `tests/shell_properties_test.cpp:5 [UTF8-LF sha256=5e82c5c20b2b39fc1efe4def4c4362bded29a81e464ed23fb16b329e98ed037b; anchor=int main()]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:105 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=editor.redrawEvent(]`. - Test-code evidence: `tests/shell_wire_test.cpp:11 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=C::Duplicate]`. -- Test-code evidence: `tests/shell_tui_test.cpp:26 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=Unchanged Enter is not a write.]`. -- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=5eda930ea11c15531c71b593dc7fa7de7a15a9376e090185774cc9bbb2a78d85; anchor=-Concern device-shell -Gate]`. +- Test-code evidence: `tests/shell_tui_test.cpp:32 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=Unchanged Enter is not a write.]`. +- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=9fb4a11ea30f14cbf306683cdbf1fdfe6042b5a3ddcc13f4a87cfb9f69b0f075; anchor=-Concern device-shell -Gate]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -4626,8 +5173,8 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `2f9c9f8f0b613d958e8bb31920174a7d8fa9f523b3477b88d9430a71d385bf48`. -- Target: `firmware/esp32s3/src/Discovery.h:11 [UTF8-LF sha256=df7e866c85b061a7b01babd6974ec37a71d25ae52e31c4e1c5ccedb327587154; anchor=class Discovery {]`. -- Target: `firmware/esp32s3/src/Discovery.h:45 [UTF8-LF sha256=df7e866c85b061a7b01babd6974ec37a71d25ae52e31c4e1c5ccedb327587154; anchor=String xmlDescription() const {]`. +- Target: `firmware/esp32s3/src/Discovery.h:11 [UTF8-LF sha256=d2cd172d2934642d6255106edb2a417a5bf128ff021dd353ad4de57262277cd0; anchor=class Discovery {]`. +- Target: `firmware/esp32s3/src/Discovery.h:45 [UTF8-LF sha256=d2cd172d2934642d6255106edb2a417a5bf128ff021dd353ad4de57262277cd0; anchor=String xmlDescription() const {]`. - Retained: Complete donor wrapper was reread by the evidence owner, in addition to network agent's full read. Actual initSSDP metadata/schema/periodic-service ownership maps to Discovery::begin/service/xmlDescription and the imported SsdpDevice::schema XML escaper. Hostname/display name/application/version are Pakar-owned; no PU850 EEPROM business identity is retained. - Omitted/replaced code: `ASA0002E/E_MainVersion/E_SerialNumber globals, include-SSDPDevice.cpp pattern, server-global registration and duplicate hand-built description`. Disposition: **replaced**. Reason: Canonical main registers the description route; instance-owned Discovery uses consistent Pakar metadata and the imported escaped schema. No duplicate compiled .cpp/global service or PU serial/version state is retained. - Omitted/replaced code: `Optional UPnP serviceList/SOAP/GENA implementation`. Disposition: **not-applicable**. Reason: The donor intentionally does not advertise an unimplemented service. Pakar likewise leaves optional service discovery disabled; no invented control protocol is exposed. Add a real contract before enabling it. @@ -4637,7 +5184,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `f9f7b078b6085e731f0ed42c5dd983d1ab0a3a48c2912797406fb3e0b0df3ea5`. -- Target: `firmware/esp32s3/src/Discovery.h:58 [UTF8-LF sha256=df7e866c85b061a7b01babd6974ec37a71d25ae52e31c4e1c5ccedb327587154; anchor=SsdpDevice ssdp_;]`. +- Target: `firmware/esp32s3/src/Discovery.h:59 [UTF8-LF sha256=d2cd172d2934642d6255106edb2a417a5bf128ff021dd353ad4de57262277cd0; anchor=SsdpDevice ssdp_;]`. - Retained: Complete tiny donor header was reread by the evidence owner. Its current role is only the historical SSDP-to-singleton alias; actual device declarations map separately to the imported SsdpDevice.h. - Omitted/replaced code: `#define SSDP SSDPDevice singleton shorthand`. Disposition: **replaced**. Reason: Explicit Discovery-owned instance avoids macro alias collisions and makes restart/teardown ownership clear; no useful implementation is silently dropped with this alias. - Remaining: Validate actual S3 AP/STA multicast membership, M-SEARCH responses, alive/update/byebye/rename lifecycle, interface-correct LOCATION, escaped description and router/phone interoperability. Source anchors and compilation do not prove on-LAN discovery. @@ -4646,8 +5193,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `82f8c2a3c9779392202dc9b864328040cf47b9303674f142e5348df2b17e34b4`. -- Target: `firmware/esp32s3/src/SsdpDevice.cpp:267 [UTF8-LF sha256=c05031ff074885374295d471878742ef2f785c6591786246e5023454118f78b9; anchor=void SsdpDevice::post(]`. -- Target: `firmware/esp32s3/src/SsdpDevice.cpp:568 [UTF8-LF sha256=c05031ff074885374295d471878742ef2f785c6591786246e5023454118f78b9; anchor=void SsdpDevice::schema(Print &client) const]`. +- Target: `firmware/esp32s3/src/SsdpDevice.cpp:255 [UTF8-LF sha256=25ffe3de9548501ec275174edbff3d9295ec1733fbea6f0fd288e4b284dc357c; anchor=void SsdpDevice::post(]`. +- Target: `firmware/esp32s3/src/SsdpDevice.cpp:546 [UTF8-LF sha256=25ffe3de9548501ec275174edbff3d9295ec1733fbea6f0fd288e4b284dc357c; anchor=void SsdpDevice::schema(Print &client) const]`. +- Target: `firmware/esp/src/UpnpDescription.h:19 [UTF8-LF sha256=c037418dca2cc7631c04f06d9dc51b4267d86aabe25d61bde4422a5430234639; anchor=template void description(]`. +- Target: `firmware/esp/src/BoardIconAsset.h:6 [UTF8-LF sha256=38856c8b4f954c2cbd9043ea2c66e311ad4736984d95ee4dde9d6bf7a8d13000; anchor=static const uint8_t kBoardIconPng[] PROGMEM]`. +- Target: `tools/generate-board-icon-asset.mjs:7 [UTF8-LF sha256=fa6093635fbbc333fe87fb1b6733453147950bc2bc2e23c10d9c7b39d9b9f078; anchor=const variants=[];]`. - Target: `firmware/esp32s3/src/SsdpUdp.h:70 [UTF8-LF sha256=e25782c43552d9a2fb570b2dd18446c99907a9b58d0d1ec6f5f190b9fee84f98; anchor=bool beginPacketMulticast(]`. - Target: `firmware/esp32s3/src/DiscoveryInterfaces.h:17 [UTF8-LF sha256=f6c87195a581b098486e533d44192b3fa728145977888caf46676f780c9a50b6; anchor=inline DiscoveryInterfaces activeDiscoveryInterfaces()]`. - Target: `firmware/esp32s3/src/EthernetPort.cpp:41 [UTF8-LF sha256=b2ef56e69ae00a5d74c6d1fb90b7cd1dbdcaf2757f440b05f52abc28938c8a0a; anchor=bool EthernetPort::begin(]`. @@ -4657,6 +5207,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Global SSDPDeviceClass singleton, ESP.getChipId and manual igmp_leavegroup/ESP8266 one-interface IGMP-pool policy`. Disposition: **replaced**. Reason: Instance-owned S3 Discovery explicitly owns teardown and stable identity; socket-scoped membership/IF/TTL replaces incompatible ESP8266 UDP internals. The adapter is bounded/nonblocking and no arbitrary shared global SPI/network engine is substituted. Actual AP/STA behavior remains unverified. - Test-code evidence: `tests/network_path_test.cpp:20 [UTF8-LF sha256=fb78d7364432aa95ae1e4fe547ee4599ec0e7f0e9a97a629c0445c6c7b842b07; anchor=int main()]`. - Test-code evidence: `tests/ethernet_discovery_test.mjs:41 [UTF8-LF sha256=84cd26938282eb3adfecec5c9464f29c817c280caf2236e96b62fd52949569f2; anchor=Ethernet discovery/build source integration checks passed]`. +- Test-code evidence: `tests/upnp_description_test.mjs:39 [UTF8-LF sha256=733ee296c2c7f106a398d297581a80acc09481f99acd9f8dc4b1444793824729; anchor=Actual UPnP schema: role PNG bytes/dimensions/depth]`. - Remaining: Validate actual S3 AP/STA multicast membership, M-SEARCH responses, alive/update/byebye/rename lifecycle, interface-correct LOCATION, escaped description and router/phone interoperability. Source anchors and compilation do not prove on-LAN discovery. - Remaining: Verify W5500 physical multicast/source routing and all-netif mDNS on actual hardware. Custom UDP lacks ingress-netif metadata: equal overlapping subnets use documented longest-prefix/tie policy, not proven exact ingress. @@ -4664,7 +5215,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `3d94b63400ab64c0e556b1e73c722b40f7adade996aaf40ce73f88d1d0ab7cf2`. -- Target: `firmware/esp32s3/src/SsdpDevice.h:98 [UTF8-LF sha256=09da08b4765186ff4549e6552ecda1791ba38369de57eb8af038e4771db85c60; anchor=class SsdpDevice {]`. +- Target: `firmware/esp32s3/src/SsdpDevice.h:98 [UTF8-LF sha256=11297a36d29bade8c44c67ce9bb9f805429c633d0f1f6164cfb2c2f9667291da; anchor=class SsdpDevice {]`. - Retained: Network agent fully read the actual donor; evidence owner reviewed the complete header diff. Retains packet templates, bounded queue and metadata fields, public discovery/description setters, cache interval and protocol structures. Adds truthful running() state from the real socket/interface. - Omitted/replaced code: `ESP8266WiFi/WiFiUDP/IGMP includes, ProductConfig and extern singleton declaration`. Disposition: **replaced**. Reason: ESP32 WiFi, explicit socket adapter, Pakar DiscoveryConfig and namespace-owned instance replace platform-specific includes/global ownership. SERVER announces ESP32-S3 rather than falsely identifying as ESP8266. Useful public protocol behavior is retained; physical discovery remains open. - Remaining: Validate actual S3 AP/STA multicast membership, M-SEARCH responses, alive/update/byebye/rename lifecycle, interface-correct LOCATION, escaped description and router/phone interoperability. Source anchors and compilation do not prove on-LAN discovery. @@ -4673,10 +5224,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `a78d88c149d19d8989c6db3dce0731189c2bf46c45fd64b73e9871a80f6c9bdd`. -- Target: `firmware/esp/src/TelnetShell.h:45 [UTF8-LF sha256=20e75d8dd8f39bea93c0df3da5aff98635b857eefb55003cfebeabd4e5c92281; anchor=class TelnetParser]`. -- Target: `firmware/esp/src/ShellService.h:270 [UTF8-LF sha256=897ac8e49f95e1e2ebc8b59dbd1a76148d1fcaaecf609cf33d99d067fc849a0c; anchor=void serviceDeviceShells()]`. +- Target: `firmware/esp/src/TelnetShell.h:46 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class TelnetParser]`. +- Target: `firmware/esp/src/ShellService.h:431 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void serviceDeviceShells()]`. - Target: `firmware/esp/src/ShellSettings.h:10 [UTF8-LF sha256=9e73a173b820cf54b70e4fc6506bccbb67fee5e4ab600fc9660503d05c491417; anchor=class ShellSettings]`. -- Target: `firmware/esp/src/ShellBuild.h:5 [UTF8-LF sha256=a2a8a9ee528e9cb48a64ad11268ff473cfbaecc8351ab01c1d9c0d55ed8128a7; anchor=PAKAR_TELNET_SESSIONS]`. +- Target: `firmware/esp/src/ShellBuild.h:5 [UTF8-LF sha256=64e2f53b7c399cd564fc41f942af57433a9baf3e0362190e138bd5a6f0ce4ce5; anchor=PAKAR_TELNET_SESSIONS]`. - Retained: Actual five-state IAC/option/subnegotiation parser, escaped IAC, server ECHO/SGA greeting and unsupported-option rejection are retained. Option state suppresses greeting-ACK negotiation loops. - Retained: Native WiFiServer/WiFiClient sessions use independent parser/editor/TUI/auth state, configurable bounded session count, 64-byte input budget, bounded output and nonblocking S3 socket writes; slow or overfull sessions retire rather than blocking LED/UART work. - Retained: ShellSettings persists enable, TCP port and color with checksum, pending write/readback/rename and unchanged-write suppression. Configuration schedules listener retirement/rebind after its response; published discovery port comes from the actual bound listener. @@ -4685,11 +5236,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Single global TelnetShellSession and legacy product greeting callbacks`. Disposition: **replaced**. Reason: Bounded independent sessions use Pakar application/hostname identity and the existing access owner. Telnet is still plaintext and is not claimed encrypted; disabling it or compile-time gating remains available. - Omitted/replaced code: `TelnetNetworkAvailable stop/restart and mDNS remove on every AP/station loss`. Disposition: **pending**. Reason: Source now supports durable enable/port rebind and truthful actual-listener discovery. Exact donor network-loss transition parity and multi-interface socket behavior still need explicit review/live acceptance rather than being inferred from settings tests. - Test-code evidence: `tests/shell_service_test.mjs:18 [UTF8-LF sha256=287a22bebfcf0457e307416b1bb6524d71a28a8876646691b449df017b023be6; anchor=Actual shell fixture extraction markers changed]`. -- Test-code evidence: `tests/shell_service_test.cpp:132 [UTF8-LF sha256=f235dde1902ae6949f8a4460a03c55b99c8886adf6befc113af9f9ea6112294b; anchor=Accept crosses millis rollover.]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:18 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=No negotiation loops on greeting acknowledgements.]`. +- Test-code evidence: `tests/shell_service_test.cpp:137 [UTF8-LF sha256=076058d45758b87cf76c294e94827929dd74a7e7075cee533aa4ee7251f7e47b; anchor=Accept crosses millis rollover.]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:18 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=No negotiation loops on greeting acknowledgements.]`. - Test-code evidence: `tests/shell_wire_test.cpp:34 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=fs.failRename=true]`. - Test-code evidence: `tests/shell_wire_test.cpp:36 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=!corrupt.enabled()]`. -- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=5eda930ea11c15531c71b593dc7fa7de7a15a9376e090185774cc9bbb2a78d85; anchor=-Concern device-shell -Gate]`. +- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=9fb4a11ea30f14cbf306683cdbf1fdfe6042b5a3ddcc13f4a87cfb9f69b0f075; anchor=-Concern device-shell -Gate]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -4698,10 +5249,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `e85f5f1a4f2767323d9886313ddb2aabfd3acad7d781c2770bc8d2b2d2c6cb15`. -- Target: `firmware/esp/src/TelnetShell.h:45 [UTF8-LF sha256=20e75d8dd8f39bea93c0df3da5aff98635b857eefb55003cfebeabd4e5c92281; anchor=class TelnetParser]`. -- Target: `firmware/esp/src/ShellService.h:270 [UTF8-LF sha256=897ac8e49f95e1e2ebc8b59dbd1a76148d1fcaaecf609cf33d99d067fc849a0c; anchor=void serviceDeviceShells()]`. +- Target: `firmware/esp/src/TelnetShell.h:46 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=class TelnetParser]`. +- Target: `firmware/esp/src/ShellService.h:431 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void serviceDeviceShells()]`. - Target: `firmware/esp/src/ShellSettings.h:10 [UTF8-LF sha256=9e73a173b820cf54b70e4fc6506bccbb67fee5e4ab600fc9660503d05c491417; anchor=class ShellSettings]`. -- Target: `firmware/esp/src/ShellBuild.h:5 [UTF8-LF sha256=a2a8a9ee528e9cb48a64ad11268ff473cfbaecc8351ab01c1d9c0d55ed8128a7; anchor=PAKAR_TELNET_SESSIONS]`. +- Target: `firmware/esp/src/ShellBuild.h:5 [UTF8-LF sha256=64e2f53b7c399cd564fc41f942af57433a9baf3e0362190e138bd5a6f0ce4ce5; anchor=PAKAR_TELNET_SESSIONS]`. - Retained: Actual five-state IAC/option/subnegotiation parser, escaped IAC, server ECHO/SGA greeting and unsupported-option rejection are retained. Option state suppresses greeting-ACK negotiation loops. - Retained: Native WiFiServer/WiFiClient sessions use independent parser/editor/TUI/auth state, configurable bounded session count, 64-byte input budget, bounded output and nonblocking S3 socket writes; slow or overfull sessions retire rather than blocking LED/UART work. - Retained: ShellSettings persists enable, TCP port and color with checksum, pending write/readback/rename and unchanged-write suppression. Configuration schedules listener retirement/rebind after its response; published discovery port comes from the actual bound listener. @@ -4710,11 +5261,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Omitted/replaced code: `Single global TelnetShellSession and legacy product greeting callbacks`. Disposition: **replaced**. Reason: Bounded independent sessions use Pakar application/hostname identity and the existing access owner. Telnet is still plaintext and is not claimed encrypted; disabling it or compile-time gating remains available. - Omitted/replaced code: `TelnetNetworkAvailable stop/restart and mDNS remove on every AP/station loss`. Disposition: **pending**. Reason: Source now supports durable enable/port rebind and truthful actual-listener discovery. Exact donor network-loss transition parity and multi-interface socket behavior still need explicit review/live acceptance rather than being inferred from settings tests. - Test-code evidence: `tests/shell_service_test.mjs:18 [UTF8-LF sha256=287a22bebfcf0457e307416b1bb6524d71a28a8876646691b449df017b023be6; anchor=Actual shell fixture extraction markers changed]`. -- Test-code evidence: `tests/shell_service_test.cpp:132 [UTF8-LF sha256=f235dde1902ae6949f8a4460a03c55b99c8886adf6befc113af9f9ea6112294b; anchor=Accept crosses millis rollover.]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:18 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=No negotiation loops on greeting acknowledgements.]`. +- Test-code evidence: `tests/shell_service_test.cpp:137 [UTF8-LF sha256=076058d45758b87cf76c294e94827929dd74a7e7075cee533aa4ee7251f7e47b; anchor=Accept crosses millis rollover.]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:18 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=No negotiation loops on greeting acknowledgements.]`. - Test-code evidence: `tests/shell_wire_test.cpp:34 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=fs.failRename=true]`. - Test-code evidence: `tests/shell_wire_test.cpp:36 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=!corrupt.enabled()]`. -- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=5eda930ea11c15531c71b593dc7fa7de7a15a9376e090185774cc9bbb2a78d85; anchor=-Concern device-shell -Gate]`. +- Test-code evidence: `tools/test-shell.ps1:5 [UTF8-LF sha256=9fb4a11ea30f14cbf306683cdbf1fdfe6042b5a3ddcc13f4a87cfb9f69b0f075; anchor=-Concern device-shell -Gate]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -4726,7 +5277,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `libraries/BoardCore/src/SerialLink.h:12 [UTF8-LF sha256=99373bc4c090e34324cfd2ae8c94d6666c227ad7e157a47fbd85125aac6c13a0; anchor=class SerialLink {]`. - Target: `libraries/BoardCore/src/UartFlow.h:26 [UTF8-LF sha256=44f8638f725c9cf83a6f53639144f107aace1556dd254d6dcebcaed4853c6469; anchor=inline uint16_t receiveBudget(]`. - Target: `libraries/BoardCore/src/Protocol.h:219 [UTF8-LF sha256=6031ecc3f760f2e4fc7a766e5977b1ea990a602de27808ddb060e56bf81c6f47; anchor=inline bool decode(]`. -- Target: `docs/PU850-PARITY.md:141 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C++ transfer and UART, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:141 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C++ transfer and UART, 2026-10-19]`. - Retained: Every transport and endpoint function read through EOF; current bounded COBS/CRC/sequence/explicit ownership remains, with exact generic pending and business-only omissions. - Retained: Complete section/function decisions and concrete unsafe legacy defects recorded in the C++ transfer and UART section of docs/PU850-PARITY.md. - Omitted/replaced code: `SOH/EOT framing/RevisionMatch/bulk ring/string endpoints/baud/drain/response match`. Disposition: **replaced**. Reason: Bounded COBS/CRC/sequence/current capability owner replaces unframed legacy ABI. Exact modulo-zero, sizeof(pointer), U16 wrapping, ten-byte string overflow, AP wrong-interface and baud fallthrough defects are recorded in PU850-PARITY. @@ -4741,7 +5292,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `libraries/BoardCore/src/SerialLink.h:12 [UTF8-LF sha256=99373bc4c090e34324cfd2ae8c94d6666c227ad7e157a47fbd85125aac6c13a0; anchor=class SerialLink {]`. - Target: `libraries/BoardCore/src/UartFlow.h:26 [UTF8-LF sha256=44f8638f725c9cf83a6f53639144f107aace1556dd254d6dcebcaed4853c6469; anchor=inline uint16_t receiveBudget(]`. - Target: `libraries/BoardCore/src/Protocol.h:219 [UTF8-LF sha256=6031ecc3f760f2e4fc7a766e5977b1ea990a602de27808ddb060e56bf81c6f47; anchor=inline bool decode(]`. -- Target: `docs/PU850-PARITY.md:141 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C++ transfer and UART, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:141 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C++ transfer and UART, 2026-10-19]`. - Retained: Every transport and endpoint function read through EOF; current bounded COBS/CRC/sequence/explicit ownership remains, with exact generic pending and business-only omissions. - Retained: Complete section/function decisions and concrete unsafe legacy defects recorded in the C++ transfer and UART section of docs/PU850-PARITY.md. - Omitted/replaced code: `SOH/EOT framing/RevisionMatch/bulk ring/string endpoints/baud/drain/response match`. Disposition: **replaced**. Reason: Bounded COBS/CRC/sequence/current capability owner replaces unframed legacy ABI. Exact modulo-zero, sizeof(pointer), U16 wrapping, ten-byte string overflow, AP wrong-interface and baud fallthrough defects are recorded in PU850-PARITY. @@ -4753,7 +5304,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `b396143fd17590b35455f91fc17fc15bfb52862d3419198df10da62b86340aaa`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: identity/overrides; secret-safe flags; all unversioned transfer routes; range/ETag/hash-pair/downstream/redaction contracts - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Full cross-language uploader/download/resume/gzip/manifest compatibility`. Disposition: **pending**. Reason: Read and adapt every actual client/test; strict current auth/identity/integrity and supported-release policy remain authoritative. PU weighing/report identity is specifically out of scope. @@ -4763,7 +5314,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `61ad4d13aa9416ac0dc2008722f2c092eceed521fd980407a23beff2258e684a`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: both profile IDs; every product/manufacturer/HTTP/WS/MNDP override; UPnP schema/service/cache/TTL/server; firmware/full-flash/bearer/redaction flags - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Unmapped generic compile-default and override knobs`. Disposition: **pending**. Reason: Reconcile actual discovery/transfer consumers individually. Preserve centralized Pakar identity, no absent SOAP/GENA advertisement or bearer bypass. @@ -4776,13 +5327,20 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `firmware/esp/src/NetworkConfig.cpp:249 [UTF8-LF sha256=9b63a2e01513fc5f11d7fe82a3e744bc3347c3aaa560320ad0b8b3a45dcedf9b; anchor=bool NetworkConfigStore::configure(]`. - Target: `firmware/esp/src/NetworkPath.h:56 [UTF8-LF sha256=cab7eb5a6e7985dfb8e5120b19991b9d434bcb4f083d5509678636b5adbda662; anchor=inline bool service()]`. - Target: `firmware/esp/src/EspFrontPanel.h:82 [UTF8-LF sha256=373453bd3730a23b62740ebcbd672d9de15bffac40068bb2e4868b4344ef8e04; anchor=Queries report the actual current connection/settings]`. -- Target: `firmware/esp32s3/src/Discovery.h:16 [UTF8-LF sha256=df7e866c85b061a7b01babd6974ec37a71d25ae52e31c4e1c5ccedb327587154; anchor=mdnsReady_ = startDiscoveryMdns(]`. -- Target: `firmware/esp32s3/src/DiscoveryMdns.h:16 [UTF8-LF sha256=ce5382414fc13567b127b6268ef1be610ae9ca566690dfc5ef0482b04039dfe4; anchor=bool startDiscoveryMdns(]`. +- Target: `firmware/esp32s3/src/Discovery.h:16 [UTF8-LF sha256=d2cd172d2934642d6255106edb2a417a5bf128ff021dd353ad4de57262277cd0; anchor=mdnsReady_ = startDiscoveryMdns(]`. +- Target: `firmware/esp32s3/src/DiscoveryMdns.h:16 [UTF8-LF sha256=970da9d6bf5565bc8d11729c002cfe45c04479621a51aa31827e637169399960; anchor=bool startDiscoveryMdns(]`. +- Target: `firmware/esp/src/StaticAssetService.h:7 [UTF8-LF sha256=959c630339f75a5385ebde79f914746041786034e6b312af25cadaac62fc0bac; anchor=inline void serveStaticAsset(]`. +- Target: `firmware/esp/src/NetworkTools.h:15 [UTF8-LF sha256=fcfdf0fd0c6aed205f4d3dfdf9942e1e4c9cd60d78e915ce5cacf1a12516f058; anchor=class NetworkTools]`. +- Target: `firmware/esp/src/NetworkToolsSettings.h:6 [UTF8-LF sha256=fd347ade47d48bab7b85545a911e3fdaccfdc95d46fa6ed011554903d529c8fd; anchor=class NetworkToolsSettings]`. +- Target: `firmware/esp/src/NetworkToolsBinding.h:4 [UTF8-LF sha256=64a39090d37d6278a1ec432694c9f1c967b1b72062bf53e33c8eae12834e776c; anchor=String networkToolsJson()]`. +- Target: `mobile/lib/advanced_page.dart:11 [UTF8-LF sha256=25b0d7098fceb6b7b381f238783a37f0ac95a975e121b6057f3efcf6631aa279; anchor=class AdvancedPage]`. +- Target: `mobile/lib/network_tools_page.dart:8 [UTF8-LF sha256=adb69dbb63ea65b2da6551dd89a8a9bc5604f75a36e845db781e62bc2bdf631e; anchor=class NetworkToolsPage]`. - Retained: Actual complete 65234-byte donor sketch read through EOF, not keyword-screened. WiFi_Setup, Settings_Read/Settings_Write, setNetFlags, hostname change and ConfirmSettings were compared to the integrated Pakar network/configuration owner. Persisted AP-only/STA-only/AP+STA, stable configured hostname without chip suffixes, station credentials, DHCP/static address/gateway/subnet/DNS and reconnect behavior map to NetworkConfigStore and NetworkPath; no second network service or PU business state is introduced. - Retained: Actual donor startup/service ownership is adapted to the S3 checked mDNS/DNS-SD registration helper and existing SSDP/MNDP interface owners. Every required service/TXT registration is checked, partial initialization is retired before bounded retry, and failed registration is not reported ready. AP SSID is explicitly bounded to32 bytes while full DNS hostname remains unchanged; a failed STA configuration preserves genuinely started AP recovery. - Retained: ESP settings are exposed through the same persisted network owner used by API/Web/native/mobile. The paired AVR front-panel adapter offers actual fresh mode/DHCP/connectivity/hostname preview and queues reconnect/scan; queries never replay stale snapshots or evict the previous mutation receipt. Scan admission is not misrepresented as completed SSID results. - Retained: Donor storage writes are adapted to checksum/exact private-layout validation, identical-write suppression, pending-file readback and rename. Invalid enabled-empty SSIDs fail closed to AP recovery; static profiles are retained while DHCP is selected. Accepted default-route and resolver changes are distinct, avoiding cloud-session teardown on DNS-only refreshes. - Retained: S3 latency policy adapts the actual donor WiFi_Setup wake/reconnect ownership to Arduino-ESP32 setSleep before and after interface startup: externally powered default explicitly disables modem sleep, with opt-in PAKAR_S3_WIFI_MODEM_SLEEP=1. Actual esp_wifi_get_ps readback is exposed separately from the requested policy; AP recovery is preserved even if setting latency policy fails. ESP8266 behavior and private persisted network layout are unchanged. +- Retained: 2026-10-28 scoped time/network follow-up: reread dormant NTPClient /ntp/get slice 1148-1180 and LocalLib/Public.cpp and RemoteCommand.cpp through EOF. Preserve configurable time/scan intent, not commented blocking forceUpdate or blanket UDP success. NetworkTools uses the existing shared command owner, bounded DNS/ICMP/verified HTTPS worker, staged typed settings and peer-bound telemetry/events. DHCP NTP is configured before interface startup; no cloud enablement or board changes are implied. - Omitted/replaced code: `ASA0002E PU-specific EEPROM/business settings and device/product globals`. Disposition: **replaced**. Reason: Pakar has a different AVR/S3 protocol, matrix/game capabilities and private flash records. The reusable network identity/settings behavior is retained through current typed owners; blindly copying PU EEPROM addresses, serial/model state or business commands would corrupt identity/settings rather than add a supported feature. - Omitted/replaced code: `Live configurable Telnet enable/port rebind and matching discovery lifecycle acceptance`. Disposition: **pending**. Reason: The device-shell slice now exposes durable enabled/port/color via typed API/cloud commands and shell properties, closes/rebinds sessions on the loop owner, and advertises only the actually bound port. Focused registration/settings fixtures do not establish real restart, network-loss and multi-client lifecycle acceptance across AP/STA/Ethernet or parity in every client settings UI. - Omitted/replaced code: `S3 NBNS live acceptance, WINS/LLMNR compatibility and complete physical network editors`. Disposition: **pending**. Reason: The link-discovery slice implements bounded interface-bound S3 NB/NBSTAT replies; physical Windows/W5500 acceptance, WINS arbitration and LLMNR remain separate unfinished work. AVR front-panel mode/DHCP/reconnect/scan still does not implement full SSID/password/static-address text editors or completed scan-result navigation; those useful features remain open. @@ -4790,19 +5348,27 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Test-code evidence: `tests/network_config_test.cpp:134 [UTF8-LF sha256=7279dda3555ee75d8029c28d998440de9ddfa095c11dedbc214107c6c961b53c; anchor=AP remains genuinely available on STA apply failure]`. - Test-code evidence: `tests/network_path_test.cpp:69 [UTF8-LF sha256=fb78d7364432aa95ae1e4fe547ee4599ec0e7f0e9a97a629c0445c6c7b842b07; anchor=state().routeChanges==fallbackRoutes]`. - Test-code evidence: `tests/esp_front_panel_test.cpp:94 [UTF8-LF sha256=58220f795fcb6d16277308285f37f7cede5af7f431ceafd675ad8bfe6f3c5013; anchor=f=request(E::Scan);f.sequence=12;service.handle(f,110]`. -- Test-code evidence: `tests/discovery_mdns_test.cpp:47 [UTF8-LF sha256=115c8090212c01bdf7c3857386a5f4e2f0b05a4840bef9100a45f723906c8e37; anchor=for(unsigned at=1;at<=15;++at)]`. +- Test-code evidence: `tests/discovery_mdns_test.cpp:52 [UTF8-LF sha256=145885cd9c34501d9f94205116bf4d231172e4a32901c17c84917120c3258f76; anchor=for(unsigned at=1;at<=15;++at)]`. +- Test-code evidence: `tests/async_http_test.cpp:253 [UTF8-LF sha256=1d3dc679c85dc177b7d255ad8a46eaa2d38f022f3f248d8c0720433aad50a9de; anchor=Static assets: negotiation, validators]`. +- Test-code evidence: `tests/web_compressed_test.mjs:27 [UTF8-LF sha256=c8b6d8c98234c067eda07d621cd8d677be1927cfa9ad6b3d091602e5d1dc917f; anchor=ETag covers actual gzip bytes]`. +- Test-code evidence: `tests/network_observation_test.cpp:4 [UTF8-LF sha256=19f9d1b0ff7d3cc14c9f54558daa2c0162656c382b01fa0a85d629544b07bec7; anchor=int main()]`. +- Test-code evidence: `tests/network_tools_settings_test.cpp:5 [UTF8-LF sha256=0b8ed672742308c4300acea079a628ead9bd0edec30b4448d63137d00868d94c; anchor=int main()]`. +- Test-code evidence: `mobile/test/network_tools_page_test.dart:43 [UTF8-LF sha256=beb532b298bee9de898b4f7d957aaf3b02f3e81812331fa17fa04926c49cf298; anchor=void main()]`. +- Test-code evidence: `tests/network_tools_wiring_test.mjs:10 [UTF8-LF sha256=f64edc0bf768ea0a67678cea42eb3abfa1e0f3e1eef4b841884fe45d45d7ef6e; anchor=const ntp =]`. +- Test-code evidence: `desktop/src/device_settings.rs:860 [UTF8-LF sha256=5bce7f14b9d6225d9aaa660838b8d07f7e14bda364c520a51f1c92e83d302ed6; anchor=fn async_wifi_scan_preserves_list_yields_between_reads_and_never_restarts_scan]`. - Remaining: Issue #2: verify persisted AP/STA/both across reboots, actual authorized DRS Tether scan/association, hostname/DHCP/static behavior and recovery without exposing credentials. Source fixtures do not prove RF, association or user visibility. - Remaining: Verify real AP/STA/Ethernet mDNS/DNS-SD/SSDP/MNDP discovery/rename and HTTP/WebSocket service under concurrent matrix/cloud traffic; resolve intermittent HTTP response loss before production acceptance. Complete the useful Telnet lifecycle, NetBIOS/LLMNR and physical text/scan editors rather than certifying whole-sketch parity. +- Remaining: Issue #7: clock/DHCP NTP, verified HTTPS public-IP/Date, SSID selection and remote MQTT/WebSocket round-trip need actual deployment/network acceptance. HTTPS never bootstraps an unset clock insecurely; IPv6 public-IP and traceroute are explicitly unsupported. No production firmware/settings/cloud policy was changed in this source pass. ### PU850-ESP: `sketch.yaml` Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `54df8b54e56a589f85b981f15996813e447fc14245ea8048c5f87cfd1f3fdf51`. -- Target: `tools/firmware-dependencies.json:2 [UTF8-LF sha256=dc64a7f76dbb2272bcd6f35a51623a372e6bff55256ae588f02a2a4d315d3207; anchor="platforms"]`. -- Target: `tools/build-firmware.ps1:185 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor=esp32:esp32:esp32s3:CDCOnBoot]`. +- Target: `tools/firmware-dependencies.json:2 [UTF8-LF sha256=95a5da284e415b35c47d8f4e9048381ec41c3e181261c437ec91b9cdcf1090c1; anchor="platforms"]`. +- Target: `tools/build-firmware.ps1:208 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor=esp32:esp32:esp32s3:CDCOnBoot]`. - Retained: Read complete donor board profile/platform/library locks. Retain exact ESP8266 core/ArduinoJson/ESP Async TCP pins where applicable, preserve existing reviewed Pakar dependency pins, and keep actual S3/AVR FQBN/flags in the canonical wrapper. - Omitted/replaced code: `PU-specific board/profile and separate dependency/config write paths`. Disposition: **replaced**. Reason: Reuse applicable exact versions/official registries in one reviewed pin owner. Preserve operator config and Pakar hardware flags; no silent downgrade, duplicate Arduino_JSON API, unsupported library additions or automatic SDK initialization. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run live registry check and review any candidate version changes with full build/hardware acceptance; pins were not upgraded by this slice. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4811,7 +5377,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `08fc271ee313bced76acf687e782aacd8eb03517e440910ff591c62aaedd0adf`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: all scalar/pointer/flash/BOOL aliases/sentinels; High/Low/IsHigh/IsLow/byte/word/Pack/ReverseOrder/Null macros - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic byte packing reconciliation`. Disposition: **pending**. Reason: Use existing stdint/typed COBS owners instead of width-dependent aliases and repeated-evaluation/token-fragment macros; full UART function review still required. @@ -4821,7 +5387,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `f7b7cf3dd40a64c4de41420a0bdd7ce8aa4685ed895b30e7f7b838cb009069c7`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: all access roles/chart classes/menu/form/list/page/reserved indices - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic Wi-Fi/settings/diagnostics/terminal/password/clock screen behavior`. Disposition: **pending**. Reason: Reconcile actual UI/session/runtime owners; only exact weighing/report/printer/calibration charts are PU business. Do not copy donor role credential hints. @@ -4831,7 +5397,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `e78774e8b164205479d73ec5bee1651160140d3fc3e5f0e204e6ff127fb958ca`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: all generic readiness/result/calendar/toggle/comparison/format constants; ADC/weigh/calibration/DLC commands/limits/brands/status/factors - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic readiness/result/calendar/format semantics`. Disposition: **pending**. Reason: Map real typed consumers in UART/clock review; only exact load-cell/calibration/weighing command groups are PU business exclusions. @@ -4841,7 +5407,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `33789905b19368d1f35d133fc38e16e8530e8841500ddfab5a0e5dcc2de421cf`. -- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:307 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: foundation slice, 2026-10-19]`. - Retained: Every section/function read through EOF: revision/icons/network/internet/control tables; every prefix/numeric/string/request/order/bulk/file/LCD/diagnostic/programming suffix; groups/HTTP/results/baud/ResultPack - Retained: Function-by-function disposition and exact remaining behaviors are recorded in docs/PU850-PARITY.md foundation slice. - Omitted/replaced code: `Generic network/clock/message/file/LCD/session/diagnostic/programming semantics`. Disposition: **pending**. Reason: Complete UART function review and import useful current authenticated behavior; a second legacy parser/version selector is replaced by living COBS/capability/ACK. Only weight/tare/receipt/report payloads are business exclusions. @@ -4856,7 +5422,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/build-all.ps1:6 [UTF8-LF sha256=8175e248b0a79227467dc334dac932454df4b59d48588673f8b9446b086f31a3; anchor=$taskSelected=[Collections.Generic.HashSet[string]]]`. - Retained: Import actual case-insensitive HashSet selection, Write-Step, Assert-Executable, Invoke-Native, scoped working directory and elapsed result. Typed hashtable splatting selects Firmware/Desktop/Mobile, defaults to plans and stops on failure. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -4870,7 +5436,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Adapt actual native-command failure preservation and build/test/publish stage dispatch into script-runtime.ps1 and build-all.ps1. Existing Rust/Flutter owners remain responsible for Pakar client outputs, with an explicit Release/preview choice. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. @@ -4880,13 +5446,13 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `0f40d6726756d86f30b82dac4fbe8fe11a5dc85ecba2cf8d68f5cb6771da0229`. -- Target: `docs/SCRIPT-TOOLING.md:94 [UTF8-LF sha256=9920569e7027308d50babfb2fd7144e1b9580d699053e7279197eebac31bb909; anchor=Actual PU850 source import]`. +- Target: `docs/SCRIPT-TOOLING.md:97 [UTF8-LF sha256=c245c9e419cddb2493d6b75b388d7f73d97ec8d69a08333d562670cbaeade3a8; anchor=Actual PU850 source import]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware]`. - Retained: Read full .NET documented upload/download/watch/backup/timeouts and platform outputs. Map one-shot preflight/identity/postboot behavior to the existing updater; document remaining download/resume/watch and package gaps rather than substituting a second client. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Content-stable auto-upload watch, resumable atomic CLI download and verified pre-update CLI backup`. Disposition: **pending**. Reason: One-shot updater and readback are not complete watch/resume/backup parity. Import these only with peer identity, stable snapshot and no ambiguous-write replay guards; keep useful donor cases open. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Implement and exercise watch/resume/export/pre-update-backup contracts and remaining donor negative cases. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. @@ -4897,11 +5463,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `727fcb1631802567cabff2c5212bc649496bfcbf39e344015653a40629705c9b`. - Target: `tools/script-console.mjs:14 [UTF8-LF sha256=2243be58e4dce21a7592865121e94ebdb64f21286f3333358c8259bb2bdedafb; anchor=export function createLogger(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: All fields/constructors and heading/info/success/warning/error/detail/upload/download/transfer/poll/clear/verbose/write/sanitize/byte/color methods - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `TransferProgress independent streams/redirected10percent/bar; PollProgress spinner`. Disposition: **pending**. Reason: Redirected progress buckets/bar/spinner remain useful pending. Actual C# independent stdout/stderr color policy is now imported into current Node logger with both redirection directions and NO_COLOR/TERM=dumb/JSON fixtures; stronger control/OSC sanitation retained. -- Test-code evidence: `tests/script_tooling_test.mjs:22 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=const independent=createLogger(]`. +- Test-code evidence: `tests/script_tooling_test.mjs:22 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=const independent=createLogger(]`. - Remaining: Redirected progress buckets/bar/spinner remain useful pending. Actual C# independent stdout/stderr color policy is now imported into current Node logger with both redirection directions and NO_COLOR/TERM=dumb/JSON fixtures; stronger control/OSC sanitation retained. ### PU850-ESP: `Tool/C#/Uploader/DownloadApp.cs` @@ -4909,10 +5475,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `14c0831141b761e303110955ba0141bc683b0d80c18890b842dce6a65f77d920`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Retained: Entire file/sections read through EOF: RunAsync/DownloadOnceAsync/Finish/ReportFailure every retry/hash/commit/error branch - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Retained: Actual Node performUpload and C# BackupInstalledFirmwareAsync backup-before-write sequencing now serves existing updater with explicit --backup/--resume-backup, first-read expected-peer fencing, metadata match and fresh auth/job/boot/latch admission before local upload or remote stage/apply. @@ -4926,7 +5492,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `aa03832b78921b5bd2d89aaf6cce4cadb26a72f940dbc3da3ea7cefcef7443fe`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Constructor/GetMetadataAsync/DownloadAsync/ExtractExpectedMd5/EnsureHashesAgree/ValidateDownloadResponse/AddAsciiMd5Header/ValidateExpectedHash/IsMd5/ComputeMd5Async/CreateClient/CreateTimeout; records/exceptions - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `GetMetadataAsync Accept-Ranges advertisement check and real transport/storage acceptance`. Disposition: **pending**. Reason: Current backup validates hashes/lengths/same peer and never truncates unknown non-resume partials. Actual advertised range support and real storage/transport/proxy/redirect acceptance remain pending; no second HTTP runtime. @@ -4938,7 +5504,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `dbb00d1703e46147b950b67837667de78c9c2b4b45899ed5bfd9187663a1ae2a`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: All resolver/cache/local-DNS/ResolveAsync branches plus ResolvedEndpoint Rebase/UriAt/UpdateInfoUri/InfoUri - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `ResolveAsync IPv6/non-local host and legacy platform behavior`. Disposition: **pending**. Reason: IPv6/non-.local resolver/legacy platform acceptance remains pending; current same-origin hostname/IPv4/TCP/board/boot admission refuses route changes/replay. @@ -4950,7 +5516,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/firmware-report.mjs:51 [UTF8-LF sha256=a374a5172a8e9d366e1bbe124311004e2f093ab648e94a54cd8c4a1ca18b4423; anchor=const gz=portableGzip(bytes),manifest=createFirmwareManifest(]`. - Target: `tools/firmware-manifest.mjs:14 [UTF8-LF sha256=fd8a77a38ee5f1b6ca8fc3df3f77cb2790bf4fc747fc22dd85315b5da113ac7c; anchor=export function parseFirmwareManifest(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: All LoadAsync/SelectFor/ComputeMd5Async/ComputeFirmwareHashesAsync/PortableFileName/generated regex methods, entries/hash/limit records and exceptions - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Streamed FileStream/GZipStream wrappers; complete paired distribution/ESP8266 gzip upload/interruption acceptance`. Disposition: **pending**. Reason: Actual parse/portable matching/strict two records/both hashes/bounded gzip code ported into real explicit ESP8266 artifact compression. Full pair distribution/ESP8266 gzip upload/interrupted packaging acceptance remains pending. @@ -4962,7 +5528,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d09ab4bcd65490aa41ffa8ae078f8052f41cce5d368480264538b14e62117e78`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Parse, all hash-selector overloads, IsAsciiHex and FlattenJson with every value-kind/fallback branch - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Parse/FlattenJson display-only scalar inspection and legacy text parsing`. Disposition: **pending**. Reason: Display-only scalar flattening/legacy text inspection remain useful pending; critical identity/hash uses exact current typed JSON instead of ambiguous nested/fuzzy leaf/fallback parsing. @@ -4973,7 +5539,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `c1f4bc597c546c6003edc4075e77681c9dac51ab500e9e71bdefdb50c7e6cc74`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Main every help/selftest/upload/download/watch/cancel/exception/finally branch plus FirstEnvironment - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Main watcher graceful-stop/hard-cancel lifecycle and actual process acceptance`. Disposition: **pending**. Reason: Current dispatcher/signal owner/JSON and stderr handling remain. Graceful watcher stop/process cancellation acceptance is pending; no encoding preference write, bearer aliases or active watcher copied. @@ -4985,7 +5551,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/firmware-report.mjs:51 [UTF8-LF sha256=a374a5172a8e9d366e1bbe124311004e2f093ab648e94a54cd8c4a1ca18b4423; anchor=const gz=portableGzip(bytes),manifest=createFirmwareManifest(]`. - Target: `tests/firmware_manifest_test.mjs:10 [UTF8-LF sha256=0e23edb29deede8a2c9aade93fc8d997d59813ea4fc5781a643d9cc42dc68776; anchor=const content=hash(raw).toUpperCase()]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: All20top-level tests; every helper/assertion; both FakeFirmwareServer/FakeDownloadServer constructors/routes/request/response/read/write/dispose/properties and TestHttpRequest - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `TestWatchStateMachine/TestWatchStopController/TestPreStoppedWatchRunnerAsync plus remaining real TCP redirect/oversize/streaming fixtures`. Disposition: **pending**. Reason: Actual TestManifestAsync cases ported to shipped source plus bounded/corrupt output and real report consumer. Remaining watcher/graceful-stop/real-TCP oversized/redirect scenarios need applicable ports; donor tests not executed as target proof. @@ -4997,7 +5563,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `55ba96cd5363807bfddd4bcc81191c93738183acdb503ae929b1350b7b79f9f9`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Constructor/UploadAsync/GetFirmwareInfoAsync/IsAcceptedResponse/CreateClient/ApplyHeaders/Truncate/RequestInfoAsync/ReadLimitedStringAsync/AppendReplaySafety/Quote; progress/serialization content and all exception classes - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `ProgressFileContent streaming and CommitTrackingMultipartFormDataContent body-start tracking`. Disposition: **pending**. Reason: Current exact raw-S3/new-boot/slot/full SHA/JSON admission remains stricter, no write replay. Applicable bounded streaming/multipart progress and request-body-start tracking remain pending; no arbitrary protected header overrides. @@ -5008,7 +5574,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `270b70fc066996277eeedcd5bb7a4f9eb2fc8278027a1711c4d615a921ea68ac`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Every SDK/output/target-framework/using/nullability/identity/version/warning/debug/determinism field - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Second .NET8 executable/runtime/assembly/debug build owner`. Disposition: **replaced**. Reason: One existing Node/PowerShell owner serves actual port; duplicate .NET8 product build/runtime/debug payload is unnecessary, not claimed binary-distribution acceptance. @@ -5018,7 +5584,7 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `34506a7f51ff0c6bd62509b9322a8f6178971ba2e67b3953829ae9835f600977`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Complete format/VS/project/GUID/Debug/Release/AnyCPU and solution properties - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Donor solution/project GUID and Debug/Release/AnyCPU build organization`. Disposition: **replaced**. Reason: Current guarded scripts/editor process tasks own scoped operations; donor solution/GUID organization has no additional runtime implementation to copy. @@ -5028,9 +5594,9 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `eaf663a9f81dc3372a378ab6332177c7b17d22170020b572a7c865575eada68f`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Retained: Entire file/sections read through EOF: RunAsync all preflight/manifest/hash/backup/skip/upload/retry/verify branches; VerifyAfterRebootAsync/WriteUsefulInfo/BackupInstalledFirmwareAsync; SynchronousProgress.Report and UploaderRuntime - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Retained: Actual Node performUpload and C# BackupInstalledFirmwareAsync backup-before-write sequencing now serves existing updater with explicit --backup/--resume-backup, first-read expected-peer fencing, metadata match and fresh auth/job/boot/latch admission before local upload or remote stage/apply. @@ -5043,7 +5609,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `dd61d42256dfa14782a6495794508fe240c7c57135316c4fa6332fbf0168234e`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: All enums/records/options/defaults/parse branches, URL/header/time/value validators, help/derived URL and ParseResult factories - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Parse applicable connect/upload/download/reboot/poll/debounce/help/verbose/backup/watch options`. Disposition: **pending**. Reason: Applicable timeout/help/verbose/backup/watch parity remains pending. Current explicit-run/strict URL/auth/identity/integrity owners reject no-verify/bearer aliases/arbitrary critical headers. @@ -5054,7 +5620,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `53c27d11d347092c0addd510ac2de5e47ecd2e7ad7fa1dd26dbd4e1a7265ad54`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Every WatchUploader, WatchStopController, BuildWatchMonitor, WatchStateMachine, observation/stable/stamp/identity member and branch - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `WatchStopController/BuildWatchMonitor/WatchStateMachine complete optional watcher lifecycle`. Disposition: **pending**. Reason: Generic event-plus-poll/generation/debounce/coalesce/graceful-stop/operation ownership/status/disposal remains useful pending real optional watcher. Existing hash+file-stability backup guard retained; metadata-only polling must not replace it. @@ -5069,7 +5635,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Retain executable preflight, bounded native failure handling and explicit selected component dispatch in shared PowerShell helpers. Windows package/capture utilities remain explicitly platform-specific, not advertised as Unix binaries. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. @@ -5084,7 +5650,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Retain Bash preflight/delegation to an explicit component owner. pakar.sh shares dispatch with Windows and CI; a MinGW cross-build of WinHTTP is not represented as a Linux-native Pakar application. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. @@ -5094,7 +5660,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `8f376d6737245386a856b2c3b568a4281ec1cff5848db24d7eb4b471dbf2b1df`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: System/processor/C/C++/RC compilers and all four root search modes - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `x64 target compilers/root-path-only dependency isolation and native distribution`. Disposition: **pending**. Reason: Existing explicit host/runtime owners replace duplicate cross-build; x64 native packaging/target-only dependency search acceptance remains pending if required. @@ -5104,7 +5670,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `5850c058156b64c0db96561a907c869fdcf53d939abf62c326dda8a34d1de5b5`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: System/processor/C/C++/RC compilers and all four root search modes - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `x86 target compilers/root-path-only dependency isolation and native distribution`. Disposition: **pending**. Reason: Existing explicit host/runtime owners replace duplicate cross-build; x86 native packaging/target-only dependency search acceptance remains pending if required. @@ -5114,7 +5680,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `0fb5ee784250ed0eb356db4ba273c9dcf6eddffc9bd8466af87f5a0d7b83d2cb`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Every platform guard/executable/standards/defines/library/MSVC/MinGW option; CTest/selftest/Python/integration/timeout/install stanza - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Windows WinHTTP/BCrypt/Winsock/Win7 native executable and distribution build contract`. Disposition: **pending**. Reason: Windows WinHTTP/BCrypt/Winsock/Win7/native binary distribution is not silently equated with Node. Existing guarded runtime/CI serves actual port; exact legacy-OS/native-distribution acceptance remains pending, no extra compiler/cache. @@ -5125,7 +5691,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f`. - Target: `tools/firmware-manifest.mjs:14 [UTF8-LF sha256=fd8a77a38ee5f1b6ca8fc3df3f77cb2790bf4fc747fc22dd85315b5da113ac7c; anchor=export function parseFirmwareManifest(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Full history/format notes/includes/limits/state; bits/stored/both SLOW and fast decode/huffman/construct/codes/fixed/dynamic/puff/invalid-distance option/error contracts - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `bits/stored/decode/construct/codes/fixed/dynamic/puff raw-deflate scan/error corpus contract`. Disposition: **pending**. Reason: Node zlib supplies bounded gzip decoder with corrupt/empty/output-bound fixture, not another longjmp/static virgin-table decoder. Full Huffman/raw-deflate scan/corpus parity remains pending; no unsafe-distance option copied. @@ -5137,7 +5703,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e`. - Target: `tools/firmware-manifest.mjs:14 [UTF8-LF sha256=fd8a77a38ee5f1b6ca8fc3df3f77cb2790bf4fc747fc22dd85315b5da113ac7c; anchor=export function parseFirmwareManifest(]`. -- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:196 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C# manifest and deflate slice, 2026-10-19]`. - Retained: Entire file/sections read through EOF: Entire Mark Adler license/version/NIL/prototype/input-output contract - Retained: Exact function dispositions are in the C# manifest/deflate slice of docs/PU850-PARITY.md. - Omitted/replaced code: `NIL/raw-deflate scan/prototype compatibility`. Disposition: **pending**. Reason: No puff code redistributed; original license retained in read-only donor, not relabeled MIT. Existing Node zlib is bounded decoder; raw-deflate scan/corpus parity needs real consumer/proof. @@ -5148,13 +5714,13 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `92c5355d3cbb855acbb60352b9250926e94856905a7845ecaf541489db5bc2d7`. -- Target: `docs/SCRIPT-TOOLING.md:94 [UTF8-LF sha256=9920569e7027308d50babfb2fd7144e1b9580d699053e7279197eebac31bb909; anchor=Actual PU850 source import]`. +- Target: `docs/SCRIPT-TOOLING.md:97 [UTF8-LF sha256=c245c9e419cddb2493d6b75b388d7f73d97ec8d69a08333d562670cbaeade3a8; anchor=Actual PU850 source import]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware]`. - Retained: Read full WinHTTP/puff/gzip/manifest, cached DNS, no-replay, atomic resume, auth and exit contracts. Retain host identity/no-replay/integrity concepts; clearly distinguish Windows cross-builds from actual Linux-native outputs. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Content-stable auto-upload watch, resumable atomic CLI download and verified pre-update CLI backup`. Disposition: **pending**. Reason: One-shot updater and readback are not complete watch/resume/backup parity. Import these only with peer identity, stable snapshot and no ambiguous-write replay guards; keep useful donor cases open. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Implement and exercise watch/resume/export/pre-update-backup contracts and remaining donor negative cases. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. @@ -5164,15 +5730,15 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `176dabcb53d4b1db79c4493b08958e1d1b0546151f6046603e589bd534308bb9`. -- Target: `tests/script_tooling_test.mjs:74 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=const cliPlan=]`. -- Target: `tests/update_tool_test.mjs:237 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=await uploadImage(origin,peer,image,identity)]`. +- Target: `tests/script_tooling_test.mjs:74 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=const cliPlan=]`. +- Target: `tests/update_tool_test.mjs:237 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=await uploadImage(origin,peer,image,identity)]`. - Retained: Adapt loopback/subprocess fixture structure and fail-before-contact/integrity assertions without requiring a WinHTTP executable or Python test server. Existing updater fixtures exercise actual multipart bytes and fragmented WS; new CLI subprocess fixtures exercise offline/invalid argument behavior. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Content-stable auto-upload watch, resumable atomic CLI download and verified pre-update CLI backup`. Disposition: **pending**. Reason: One-shot updater and readback are not complete watch/resume/backup parity. Import these only with peer identity, stable snapshot and no ambiguous-write replay guards; keep useful donor cases open. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. -- Test-code evidence: `tests/update_tool_test.mjs:290 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=Automatic updater plan/preflight]`. +- Test-code evidence: `tests/update_tool_test.mjs:291 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=Automatic updater plan/preflight]`. - Remaining: Implement and exercise watch/resume/export/pre-update-backup contracts and remaining donor negative cases. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5183,10 +5749,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/download-firmware.mjs:25 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export function parseDigestMD5(]`. - Target: `tools/check-ethernet-live.mjs:57 [UTF8-LF sha256=29e5e90657ea68415c49f947e09561a8750f7998b972e88d689bc6b79c82b08c; anchor=distinctHeaders:response.headersDistinct]`. -- Target: `docs/PU850-PARITY.md:141 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: C++ transfer and UART, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:141 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: C++ transfer and UART, 2026-10-19]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Retained: Actual single_header/parse_header_md5/collect_download_hashes implementation adapted for critical duplicate rejection and canonical full Digest-MD5; range Content-MD5 remains section integrity. - Retained: Complete section/function decisions and concrete unsafe legacy defects recorded in the C++ transfer and UART section of docs/PU850-PARITY.md. - Retained: Actual Node performUpload and C# BackupInstalledFirmwareAsync backup-before-write sequencing now serves existing updater with explicit --backup/--resume-backup, first-read expected-peer fencing, metadata match and fresh auth/job/boot/latch admission before local upload or remote stage/apply. @@ -5206,7 +5772,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Adapt actual selected-target orchestration, tool preflight and raw native exit preservation without installing a duplicate Go application stack. Existing Rust checks/tests/Release and Flutter preview remain explicit. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. @@ -5221,7 +5787,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Retain shell launcher preflight and one delegated owner with explicit testing/build selection. Pakar Bash and Windows now reach identical guarded component wrappers rather than separate drifting build recipes. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. - Omitted/replaced code: `Forced cleans/no-cache recompiles, PU profile/name, timestamp/personal build headers and audible failure side effects`. Disposition: **replaced**. Reason: Use existing Pakar board/link flags and generated-file checks, stable warm output/cache paths and quiet explicit actions. Do not wear SSDs, alter identity or start devices as an implicit build action. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. - Remaining: Run configured OS-matrix command plans and affected real SDK builds on their matching hosts. @@ -5232,7 +5798,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `c6d5b649d1ff9ceb7469a7dad0f1020e6fb0e085fed9b94dda438660f5c451b6`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: enableVirtualTerminal/isCharacterDevice call - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Go terminal runtime copy`. Disposition: **replaced**. Reason: Existing Node TTY/NO_COLOR/TERM ownership serves script terminal detection without a duplicate Go runtime or terminal mutation. @@ -5242,7 +5808,7 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `fb558b089a87dd62ba560db249402d424428c45f50c1286c656de4b8f8a9b440`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: VT flag/kernel32 procedures; complete enableVirtualTerminal GetConsoleMode/SetConsoleMode flow - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Legacy Windows console VT acceptance`. Disposition: **pending**. Reason: Current script logger and native CLI own terminal setup; prove actual older Windows VT behavior before closing this generic gap. @@ -5253,10 +5819,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `c33bfe1e8ebfabbf03db06a96ced7e08045599cbc1bda7592b04cdd5a3942fc7`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Retained: Complete EOF read: every test function/helper and all HTTP branches - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Retained: Actual Node performUpload and C# BackupInstalledFirmwareAsync backup-before-write sequencing now serves existing updater with explicit --backup/--resume-backup, first-read expected-peer fencing, metadata match and fresh auth/job/boot/latch admission before local upload or remote stage/apply. @@ -5270,7 +5836,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `06cd1dd3150f7b0bd7f9b57f90a1ae3731767374d43527de296bdf91be3b1185`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Actual Range/length/hash/resume/partial/commit flow adapted into tools/download-firmware.mjs and invoked by guarded wrapper/pakar download. - Retained: Complete EOF read: all constants/regex/types/functions - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. @@ -5283,7 +5849,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `0a7dadd87990be9f31ff22f3c056e96f8210540cd2bf1c637c84bbc756f63ed8`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: module path and go1.22; no third-party dependencies - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Additional Go application module/runtime`. Disposition: **replaced**. Reason: Use the existing Node/PowerShell client dispatcher for actual ported code; no donor build or new toolchain/dependency/cache. @@ -5293,7 +5859,7 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `c3efa93383cc376123f500e279bc16686d611229141157162e750b44813cf79e`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: all nine tests/all HTTP branches - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Applicable gzip/manifest/env-priority/filename tests`. Disposition: **pending**. Reason: Existing script/update fixtures cover current help/plan/multipart/new-boot/secret/exit behavior; port remaining scenarios with actual generic implementation. Bearer aliases/fuzzy scalar suffixes are replaced by exact current admission; no donor test execution claim. @@ -5305,10 +5871,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d44049897ca5c86fb3748c42f1871a9f066ae2f665951393b2502250249f7229`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Retained: Complete EOF read: all imports/constants/types/options and every main/CLI/endpoint/HTTP/upload/manifest/hash/gzip/multipart/reboot/info/console/progress function - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Retained: Actual Node performUpload and C# BackupInstalledFirmwareAsync backup-before-write sequencing now serves existing updater with explicit --backup/--resume-backup, first-read expected-peer fencing, metadata match and fresh auth/job/boot/latch admission before local upload or remote stage/apply. @@ -5320,13 +5886,13 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `d9142cd0128d3b963259c9eaaf5afc179c120d232f9d80d6e401591ecac6bb40`. -- Target: `docs/SCRIPT-TOOLING.md:94 [UTF8-LF sha256=9920569e7027308d50babfb2fd7144e1b9580d699053e7279197eebac31bb909; anchor=Actual PU850 source import]`. +- Target: `docs/SCRIPT-TOOLING.md:97 [UTF8-LF sha256=c245c9e419cddb2493d6b75b388d7f73d97ec8d69a08333d562670cbaeade3a8; anchor=Actual PU850 source import]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware]`. - Retained: Read full standard-library client, atomic verified download, backup/resume/watch, auth and multi-target build contract. Retain bounded verification and hostname caching intent in the existing updater; no claim that read-only export loops are a resumable CLI backup command. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Content-stable auto-upload watch, resumable atomic CLI download and verified pre-update CLI backup`. Disposition: **pending**. Reason: One-shot updater and readback are not complete watch/resume/backup parity. Import these only with peer identity, stable snapshot and no ambiguous-write replay guards; keep useful donor cases open. - Omitted/replaced code: `Build and distribute parallel .NET/WinHTTP/Go ASA clients`. Disposition: **replaced**. Reason: Pakar already owns Rust and Flutter applications and the standard-library Node updater. Avoid three duplicate client stacks/SDK caches; reusable command/preflight/transfer contracts are mapped separately. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Implement and exercise watch/resume/export/pre-update-backup contracts and remaining donor negative cases. - Remaining: Verify full native distributable/platform parity; Windows cross-build output is not Linux-native execution. @@ -5337,7 +5903,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `b9c6d47bb4072251c72002984676b420794a4e05c3c056a19afb4b4b3b2557b5`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: atomicReplace/os.Rename - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Replace-existing rename`. Disposition: **replaced**. Reason: Actual download commit uses atomic no-overwrite link+unlink on its verified private partial; existing destination and failed partial are preserved. No delete fallback. @@ -5347,7 +5913,7 @@ Verdict: **equivalent-behavior**. Revision: `431104072952f694a35b3a83c19f13fbb09 Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `892dbd97703bb64d5c2ff3b6916a808350085eb034b5a7583ae5416e04a698bf`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: MoveFileExW flags/procedure/UTF16 conversion/atomicReplace - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `WRITE_THROUGH durability and unsupported filesystem acceptance`. Disposition: **pending**. Reason: Current atomic no-overwrite commit avoids replacing another backup. Hard-link unsupported filesystems fail retaining partial; power-loss/write-through durability is not certified. @@ -5358,7 +5924,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `ed25ba3b659d148914bc58ef87cf5fa5d63cb68966ce26b3d40108965888fd91`. - Target: `tools/pakar.mjs:59 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=export async function main(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: all nine tests plus writeWatchBuild/rewriteWatchBuild and each file/sentinel branch - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Remaining generic stable-watch scenarios`. Disposition: **pending**. Reason: Actual changed-content guard is exercised in backup fixture. Stable-window/no replay/newest-during-upload/deletion/sentinels/content-vs-metadata/mismatch/dedupe/missing-watch tests must follow actual optional watcher implementation. @@ -5370,7 +5936,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `46468b3f00791504df0b62d58adfbf6f416b840875febecf7a46868ae7051d40`. - Target: `tools/download-firmware.mjs:55 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export async function downloadFirmware(]`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Actual digestUnchangedFile size/time/mode stability checks adapted around the held backup file hash and final commit. - Retained: Complete EOF read: all constants/errors/types/state branches/functions/methods - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. @@ -5387,7 +5953,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Retain stable help/exit, quiet output, explicit validation and safe transfer intent. Document the Pakar updater's current contract and guarded entrypoints; do not claim documented donor watch/resume/backup behavior is already implemented. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Content-stable auto-upload watch, resumable atomic CLI download and verified pre-update CLI backup`. Disposition: **pending**. Reason: One-shot updater and readback are not complete watch/resume/backup parity. Import these only with peer identity, stable snapshot and no ambiguous-write replay guards; keep useful donor cases open. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Implement and exercise watch/resume/export/pre-update-backup contracts and remaining donor negative cases. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5400,7 +5966,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/pakar.mjs:65 [UTF8-LF sha256=0875bb298a832a275d62e3595c2e2691c38eef442ac5246a85fb4445c3715bd3; anchor=Node.js20.19]`. - Retained: Read exact chalk/chokidar/readdirp locked graph. Shared console implements required logger/color/terminal sanitation with Node standard libraries; no new node_modules or lockfile is needed for currently implemented one-shot commands. - Omitted/replaced code: `New chalk/chokidar/readdirp npm installation for one-shot tools`. Disposition: **replaced**. Reason: Currently required console/argv behavior uses Node standard libraries and no file watcher is advertised. Preserve zero additional npm tree; advanced watcher parity remains explicit. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Complete guarded content-stable watch behavior before claiming uploader dependency/feature parity. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5410,10 +5976,10 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `60754d1116254eeac6b10e626c2e2b4506e60ad3ecd0296ce59645d63858e540`. - Target: `tools/script-console.mjs:5 [UTF8-LF sha256=2243be58e4dce21a7592865121e94ebdb64f21286f3333358c8259bb2bdedafb; anchor=export const EXIT]`. -- Target: `.github/workflows/build.yml:88 [UTF8-LF sha256=83a9772a380f29a70886fad3b0462c3f17aa0c46f565d9feec32c57aba37c498; anchor=node tests/script_tooling_test.mjs]`. +- Target: `.github/workflows/build.yml:90 [UTF8-LF sha256=2011a5c6db4cce2042b223c1724d4fc6481d140f7aaf68527bc0f8ccf79c363e; anchor=node tests/script_tooling_test.mjs]`. - Retained: Retain explicit Node20.19 minimum and direct Node entrypoints, tests and help without an additional npm install. Shared logger/CLI use Node standard libraries; unavailable watch mode is recorded as a gap. - Omitted/replaced code: `New chalk/chokidar/readdirp npm installation for one-shot tools`. Disposition: **replaced**. Reason: Currently required console/argv behavior uses Node standard libraries and no file watcher is advertised. Preserve zero additional npm tree; advanced watcher parity remains explicit. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Complete guarded content-stable watch behavior before claiming uploader dependency/feature parity. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5427,7 +5993,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: Map actual uploader/build/CLI usage to the shared Pakar command reference and tools. Clearly separate raw S3/JSON verification from ASA gzip/ok!, plans from execution and current one-shot support from pending advanced transfer modes. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Content-stable auto-upload watch, resumable atomic CLI download and verified pre-update CLI backup`. Disposition: **pending**. Reason: One-shot updater and readback are not complete watch/resume/backup parity. Import these only with peer identity, stable snapshot and no ambiguous-write replay guards; keep useful donor cases open. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Implement and exercise watch/resume/export/pre-update-backup contracts and remaining donor negative cases. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5440,7 +6006,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Target: `tools/verify-running-firmware.mjs:35 [UTF8-LF sha256=44264fe63ba4c81079517521f8bc0cde96e4602d0d56ab23dfed0356bccdaa9a; anchor=export async function verifyRunningFirmware(]`. - Target: `tools/download-firmware.mjs:14 [UTF8-LF sha256=1fd7047bd6c35cc97eb30f6c9073f841db06b74bc5794c0278098e627bc466c8; anchor=export const verificationRangeBytes=16*1024;]`. - Retained: Import actual logger, terminal sanitizer, stable exit categories, quiet/color rules and argv/preflight concepts into script-console.mjs/pakar.mjs. Correct OSC-before-ESC regex ordering and redirected stderr coloring. Existing updater retains hostname/TCP identity pinning, no uncertain-write replay and full postboot verification. @@ -5449,11 +6015,11 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O - Retained: After the same live post-upload readback timeout recurred beyond about 1 MiB, reread the donor verifyAfterReboot/verifiedDownload functions and existing updater/verifier owners. Both post-reboot verification paths now import one 16 KiB range constant from download-firmware, without circular imports or changing that module's successful 64 KiB backup transfers. Updater progress records only verified bytes. The actual updater fixture crosses 1 MiB, tests the final short range, one same-range pinned read retry, second timeout and invalid late range without replaying any upload. This is source/fixture evidence; no new board mutation was used to test this tool change. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Stable watcher/coalescing/graceful-stop/progress and remaining negative cases`. Disposition: **pending**. Reason: Actual verified pre-update backup and ranged/resumable export now implemented through existing owners with same-peer/release/auth/job/boot/latch and no-overwrite checks. Watch/coalescing/graceful stop/skip-identical/progress, remaining gzip consumer/platform/filesystem/transport and exact unported negative scenarios remain pending as applicable. No donor tests, SDK builds, live update/restore or physical proof claimed. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Test-code evidence: `tests/update_backup_test.mjs:53 [UTF8-LF sha256=9fad7b056864a821193c642fbc8739ca60f6b490fc358853906564fd242b75cc; anchor=Backup must precede even remote staging]`. - Test-code evidence: `tests/verify_running_firmware_test.mjs:115 [UTF8-LF sha256=77f9d8389d7668b39cdfb57b71a9ec9acba3da9b439bf7feefdb284d98a04058; anchor=Read-only running firmware verifier:]`. -- Test-code evidence: `tests/update_tool_test.mjs:113 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=Exercise the actual post-reboot loop past the observed ~1 MiB failure point.]`. +- Test-code evidence: `tests/update_tool_test.mjs:113 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=Exercise the actual post-reboot loop past the observed ~1 MiB failure point.]`. - Remaining: Explicit verified backup-before-update and resumable export now imported. Remaining stable watcher/coalescing/graceful stop/skip-identical/progress, gzip consumer/platform/filesystem/transport and unported negative scenarios need applicable implementation/acceptance; no source fixture closes live/physical/restore gates. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5461,18 +6027,18 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `02842871c7f5c1285b57c1cc2392d655df848ef993c138f6a6c11b9779bf0778`. -- Target: `tests/script_tooling_test.mjs:74 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=const cliPlan=]`. -- Target: `tests/update_tool_test.mjs:173 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=const server=createServer]`. +- Target: `tests/script_tooling_test.mjs:74 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=const cliPlan=]`. +- Target: `tests/update_tool_test.mjs:173 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=const server=createServer]`. - Target: `tools/update-firmware.mjs:216 [UTF8-LF sha256=f1661663563a2dcd4eaed9d1a054cc986cf03d8e3b0806bc93d6bf07560ad068; anchor=export async function updateFirmware(]`. - Target: `tools/peer-identity.mjs:54 [UTF8-LF sha256=869e9ea5db1887dde58b170c66d4c2b379aa6fb7d3c123adc5c023e1b8db1ce7; anchor=export function boardPeer(]`. -- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:3 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Actual backup-before-update orchestration, 2026-10-19]`. - Retained: Adapt actual subprocess execution and no-device-contact assertions into script_tooling_test.mjs for offline raw-image planning and invalid mode rejection. Existing real loopback multipart/WS updater fixtures retain exact byte/length/hash/admission and postboot checks, not ASA response parsing. - Retained: Actual Node performUpload and C# BackupInstalledFirmwareAsync backup-before-write sequencing now serves existing updater with explicit --backup/--resume-backup, first-read expected-peer fencing, metadata match and fresh auth/job/boot/latch admission before local upload or remote stage/apply. - Omitted/replaced code: `ASA gzip/two-record manifest, legacy plain-info fallback and exact ok! reply; verification bypass`. Disposition: **replaced**. Reason: Current Pakar S3 uses raw application validation and structured JSON/update endpoints with board/boot fences. Mandatory auth-off/new-boot/full-SHA checks must not be weakened to preserve an incompatible donor wire protocol. - Omitted/replaced code: `Stable watcher/coalescing/graceful-stop/progress and remaining negative cases`. Disposition: **pending**. Reason: Actual verified pre-update backup and ranged/resumable export now implemented through existing owners with same-peer/release/auth/job/boot/latch and no-overwrite checks. Watch/coalescing/graceful stop/skip-identical/progress, remaining gzip consumer/platform/filesystem/transport and exact unported negative scenarios remain pending as applicable. No donor tests, SDK builds, live update/restore or physical proof claimed. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. -- Test-code evidence: `tests/update_tool_test.mjs:290 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=Automatic updater plan/preflight]`. +- Test-code evidence: `tests/update_tool_test.mjs:291 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=Automatic updater plan/preflight]`. - Test-code evidence: `tests/update_backup_test.mjs:53 [UTF8-LF sha256=9fad7b056864a821193c642fbc8739ca60f6b490fc358853906564fd242b75cc; anchor=Backup must precede even remote staging]`. - Remaining: Explicit verified backup-before-update and resumable export now imported. Remaining stable watcher/coalescing/graceful stop/skip-identical/progress, gzip consumer/platform/filesystem/transport and unported negative scenarios need applicable implementation/acceptance; no source fixture closes live/physical/restore gates. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5481,7 +6047,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `cb1e505e9fd5df220e976b3a42e5baedeeef8d8c6e8e0f17fd4c6a6b58f26206`. -- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=fa0f798b253216dae2da86de732cdeeeb522fc16557b5b30634cf47a203f589e; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. +- Target: `docs/PU850-PARITY.md:244 [UTF8-LF sha256=90fd0fcaa4c6bfab14639db6e6fa479a2f70729100ed5800edd73020f494d1a3; anchor=## Exhaustive continuation: editor and Go client slice, 2026-10-19]`. - Retained: Complete EOF read: complete HTML/CSS and initWebSocket/onOpen/onClose/onMessage/onLoad/initButton/toggle - Retained: Exact function-by-function adaptations, safety defects and remaining contracts are recorded in the editor/Go slice of docs/PU850-PARITY.md. - Omitted/replaced code: `Standalone generic endpoint/message inspector`. Disposition: **pending**. Reason: Current Web socket/reconnect/safe text ownership replaces hardcoded PU hostname, innerHTML injection, unbounded dots/fixed retry/unguarded sends. Standalone inspector remains useful pending real auth/identity design. @@ -5492,12 +6058,12 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. Original-byte SHA-256: `2e1870cb90db3459eb4abb2e991605409621021fb33b5e61cb38efc82e30d75b`. - Target: `tools/firmware-dependencies.mjs:62 [UTF8-LF sha256=903bd1fdd6e50218cab22db4fc21fb64aee44da2b025faf329fcee4703506ab1; anchor=export async function checkDependencies]`. -- Target: `tools/firmware-dependencies.json:2 [UTF8-LF sha256=dc64a7f76dbb2272bcd6f35a51623a372e6bff55256ae588f02a2a4d315d3207; anchor="platforms"]`. +- Target: `tools/firmware-dependencies.json:2 [UTF8-LF sha256=95a5da284e415b35c47d8f4e9048381ec41c3e181261c437ec91b9cdcf1090c1; anchor="platforms"]`. - Target: `upgrade.sh:4 [UTF8-LF sha256=2d02c4404e822f167989e965061d705f87896ec3069efb39d8bce1ad644f96d7; anchor=dependencies "$@"]`. - Target: `upgrade.cmd:3 [UTF8-LF sha256=8e8614d6dc26192f66ef8e842a3dd53a3a5b66272a53d7d3bff7c275ea454dcb; anchor=dependencies]`. - Retained: Adapt exact-name registry indexing, check/apply operation and one cached index into firmware-dependencies.mjs. Improve lexical version selection to numeric stable comparison, retain pinned-ahead versions, detect concurrent edits and install only explicitly locked versions. - Omitted/replaced code: `PU-specific board/profile and separate dependency/config write paths`. Disposition: **replaced**. Reason: Reuse applicable exact versions/official registries in one reviewed pin owner. Preserve operator config and Pakar hardware flags; no silent downgrade, duplicate Arduino_JSON API, unsupported library additions or automatic SDK initialization. -- Test-code evidence: `tests/script_tooling_test.mjs:130 [UTF8-LF sha256=9aa75b94bda7dec8ae34f4e4ad9bcde0b94bb7005437fc30f6613c33b0d37054; anchor=Script tooling:]`. +- Test-code evidence: `tests/script_tooling_test.mjs:133 [UTF8-LF sha256=8cecc892a9f416c7fd4d7df6aae64cef7792b557e73213bc77d9e6a4a53c1380; anchor=Script tooling:]`. - Test-code evidence: `tests/script_tooling_test.ps1:26 [UTF8-LF sha256=d3c679d8caa22177e7a5590564cd1141b84e1b194b3c3ee0bb825c26a4d46305; anchor=PowerShell utilities parse]`. - Remaining: Run live registry check and review any candidate version changes with full build/hardware acceptance; pins were not upgraded by this slice. - Remaining: No physical deployment, full client distribution or donor-wide completion is inferred from source or fixtures. @@ -5506,7 +6072,7 @@ Verdict: **parity-gap**. Revision: `431104072952f694a35b3a83c19f13fbb098ca5b`. O Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `fa26772dc3f07e02815543e65fe5d90de6415c5b0803aa0e4416aeb8a63b0b31`. -- Target: `tools/build-firmware.ps1:165 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor=MiniCore:avr:328:bootloader=uart0]`. +- Target: `tools/build-firmware.ps1:188 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor=MiniCore:avr:328:bootloader=uart0]`. - Retained: Read the complete donor profile. External 16 MHz operation, UART bootloader, EEPROM retention and 2.7 V brownout protection remain explicit in the canonical AVR build owner. - Omitted/replaced code: `MightyCore ATmega32 board/pinout`. Disposition: **replaced**. Reason: The fitted controller is ATmega328P. MiniCore modelP is the reviewed target; copying this editor profile would compile for the wrong MCU and memory/pin map. - Omitted/replaced code: `fixed COM1 and USBasp programmer`. Disposition: **replaced**. Reason: COM1 is commonly the ASUS built-in port. Backup-first deployment inventories and pins the newly enumerated device/programmer instead of risking a stale fixed target. @@ -5519,7 +6085,7 @@ Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `ca7d6699a966afa9e1132a4a83359978fa9ace81d2bee295662fa5ba0bd8ca53`. - Target: `.vscode/settings.json:5 [UTF8-LF sha256=2373a0d9d9a7af54872b0ae854ccf1ff95116ef8f80aebfad513fe3031914d5f; anchor="*.ino": "cpp"]`. -- Target: `tools/build-firmware.ps1:212 [UTF8-LF sha256=8c5355e77c691452e986dd2667e017189be67ead6a9797490e277c752d2d6b85; anchor='compile', '--jobs', "$Jobs", '--fqbn']`. +- Target: `tools/build-firmware.ps1:235 [UTF8-LF sha256=83593d793045279d5943f166c93f1faca9263d52725ed056f5963cadaecd59d7; anchor='compile', '--jobs', "$Jobs", '--fqbn']`. - Retained: Read all 536 donor lines through EOF. Arduino/AVR C++ source association and target compile-profile intent are retained by portable workspace associations and the actual arduino-cli invocation. - Omitted/replaced code: `absolute Arduino15 compiler/core/include paths and tool version`. Disposition: **replaced**. Reason: Generated per-user paths become stale when packages move. The wrapper resolves the installed toolchain and selected FQBN at build time. - Omitted/replaced code: `ATmega32/USBCON and hundreds of copied compiler predefined macros`. Disposition: **replaced**. Reason: The selected compiler must define its real ATmega328P ABI and capabilities; a committed ATmega32 snapshot would make IntelliSense contradict the production build. @@ -5545,7 +6111,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `libraries/BoardCore/src/pccontroller/Keys.cpp:19 [UTF8-LF sha256=d9b8c8d4ec6beef667c01c5b5e3af539ead2429393e2632c4c59461f30c14224; anchor=void Key::updateSample]`. - Retained: Read the complete debounce fragment. Its raw-edge timestamp, stable-state comparison and one press action after a bounded quiet interval are retained in the shared front-key classifier. - Omitted/replaced code: `fixed 50 ms press-only snippet with unspecified initialization and callbacks`. Disposition: **replaced**. Reason: The shared 20 ms classifier initializes deterministically, handles unsigned rollover and emits Down immediately after debounce while also classifying Up/click/double-click/hold without delaying the primary action. -- Test-code evidence: `tests/pc_contract_test.cpp:295 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=key.updateSample(true,625)]`. +- Test-code evidence: `tests/pc_contract_test.cpp:298 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=key.updateSample(true,625)]`. - Scoped advantage: Generalizes the useful debounce edge into a bounded rollover-safe lifecycle while reducing primary-action latency and avoiding delayed click classification on the winning path. - Remaining: Physical contact-bounce and first-press latency still require measurement on the fitted four-box buttons; host timing fixtures do not prove switch hardware. @@ -5556,7 +6122,7 @@ Verdict: **parity-gap**. Revision: `filesystem-snapshot`. Original-byte SHA-256: - Target: `firmware/avr/FrontPanel.cpp:271 [UTF8-LF sha256=d90acc624b6d9e9248238eb5015a573bb3081bde5546b9bdc3d1e14661f3dea4; anchor=Imported from ControllerBoardMini/Puzzles::animation()]`. - Target: `firmware/avr/SevenSegments.cpp:385 [UTF8-LF sha256=73b97393db8937820b153c4b776014e2dda672c579a179f133777b1fe5107997; anchor=void SevenSegments::service(uint32_t now)]`. - Target: `firmware/avr/ShiftRegisters.cpp:55 [UTF8-LF sha256=990c51a59be04c29618232bd86f2857f9fddc65eb70d7585ad2f537c8a46b902; anchor=void ShiftRegisters::service()]`. -- Target: `firmware/avr/TonePlayer.cpp:61 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=void TonePlayer::update(uint32_t now)]`. +- Target: `firmware/avr/TonePlayer.cpp:62 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=void TonePlayer::update(uint32_t now)]`. - Retained: Read all 1223 donor lines through EOF. Preserved its 12-step four-digit perimeter animation, active-low input/relay service concepts, temporary status colors, valid/error audio distinction, serial input diagnostics and cooperative service intent. - Retained: The target uses board-confirmed A4/DIO and A5/CLK TM1637, one mode-aware shift-register owner, nonblocking tone queue and framed UART; the perimeter animation is a bounded semantic state rather than an always-running idle distraction. - Omitted/replaced code: `ATmega32 PC6/PC7/PC3-PC5, PD6/PB4 and LCD A1-A7 pin map`. Disposition: **replaced**. Reason: Those pins belong to a different controller. Pakar retains the confirmed ATmega328P shift-register map and A4/A5 TM1637 map; copying this donor map would break fitted UART, ISP and display hardware. @@ -5581,7 +6147,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Omitted/replaced code: `avrdude -V and shell-composed command string`. Disposition: **replaced**. Reason: Disabling verification and passing interpolated paths through a shell can corrupt or execute unintended input. Current flows hash, back up, verify/read back and use bounded structured requests. - Omitted/replaced code: `any unauthenticated fileModified Socket.IO event immediately writes flash`. Disposition: **replaced**. Reason: Firmware mutation requires an explicit run/apply action, compatible peer identity, exact board/boot fencing, size/hash/profile admission and no automatic replay after an uncertain write. - Omitted/replaced code: `unbounded server trust and line-by-line raw stdout publication`. Disposition: **replaced**. Reason: Typed progress/status events redact credentials, bind to one operation identity and distinguish staged, accepted, rebooting, verified and failed outcomes. -- Test-code evidence: `tests/update_tool_test.mjs:106 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=No retry/fallback after mutation]`. +- Test-code evidence: `tests/update_tool_test.mjs:106 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=No retry/fallback after mutation]`. - Test-code evidence: `tests/update_backup_test.mjs:75 [UTF8-LF sha256=9fad7b056864a821193c642fbc8739ca60f6b490fc358853906564fd242b75cc; anchor=Verified backup must exist BEFORE the one write]`. - Scoped advantage: Turns the donor's unauthenticated fire-on-event programmer into an explicit backup-first, identity-fenced, integrity-verified, no-replay updater shared by CLI and clients. - Remaining: Physical USB/OTA/ISP update, power-loss recovery and new-boot verification remain live board gates; fixtures do not authorize or prove a flash. @@ -5600,7 +6166,7 @@ Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `03d35d925349974134ff5a047503ac6772459893c6ef89219b825ef60cd0d5ed`. -- Target: `desktop/Cargo.toml:23 [UTF8-LF sha256=5410c74841a5fa678f6a5b7ce1f9f372f0c4a39077f36bf7484c1de2203f9157; anchor=tungstenite =]`. +- Target: `desktop/Cargo.toml:23 [UTF8-LF sha256=e330c25d8ba662bc8ffb5b848f669cc7af8882b13deff29e91606b43c90ff233; anchor=tungstenite =]`. - Retained: Read the complete dependency manifest. WebSocket client/server, HTTP/TLS, hashing and structured serialization needs are owned by the native Rust client and firmware platform rather than a second Node update daemon. - Omitted/replaced code: `chalk, chokidar, Socket.IO and ws runtime stack`. Disposition: **replaced**. Reason: The production native app already uses pinned rustls HTTP/WebSocket crates and the board uses its bounded embedded services. A parallel Node daemon would duplicate connection/update authority. - Omitted/replaced code: `http 0.0.1-security placeholder and missing tmp dependency used by Client.js`. Disposition: **replaced**. Reason: The placeholder adds no HTTP implementation and the manifest cannot reproduce its own client. Current builds use declared, locked dependencies or Node standard modules only. @@ -5614,7 +6180,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Retained: Read the complete launcher. Preserved a simple cross-platform updater entry point and clear failure reporting without silently executing a newly downloaded script. - Omitted/replaced code: `infinite unauthenticated HTTP curl/download/overwrite/execute loop`. Disposition: **replaced**. Reason: It provides neither integrity nor rollback and will repeatedly execute whatever localhost:800 serves. Current update defaults to a plan and requires explicit -Run after source/image/peer validation. - Omitted/replaced code: `delete temporary failure and retry every three seconds forever`. Disposition: **replaced**. Reason: Unknown or failed mutations are retained as inspectable receipts and are never automatically replayed; operators receive a finite actionable failure. -- Test-code evidence: `tests/update_tool_test.mjs:106 [UTF8-LF sha256=0e6180f0beb443a5d092bf3b2c7496cb8a28f4a143c9d09a04d342d52b14696b; anchor=No retry/fallback after mutation]`. +- Test-code evidence: `tests/update_tool_test.mjs:106 [UTF8-LF sha256=f77db2458acdf69bc16d9e187911d2856e8b4990027371bd7ed85d4efc192889; anchor=No retry/fallback after mutation]`. - Scoped advantage: Replaces automatic remote-code execution and retry storms with a reviewed, explicit, finite updater that defaults to no device write. - Remaining: The prepared four-box images still require a newly enumerated ASUS USB device before the explicit run path can perform backup-first deployment. @@ -5636,7 +6202,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `9494993a0aa031a75b709a47a6220330c9b4bb4e90d5a7e9898a37ad9111599a`. -- Target: `firmware/esp/src/LedGrid.cpp:317 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=Wall-clock interpolation]`. +- Target: `firmware/esp/src/LedGrid.cpp:330 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=Wall-clock interpolation]`. - Target: `firmware/avr/BootMelody.cpp:22 [UTF8-LF sha256=fad1505f9cedc196236fc782ae2ec71a3c9fb8784659a1955affdf9e44137247; anchor=case kCueWelcome40Led:]`. - Target: `firmware/avr/ShiftRegisters.cpp:55 [UTF8-LF sha256=990c51a59be04c29618232bd86f2857f9fddc65eb70d7585ad2f537c8a46b902; anchor=void ShiftRegisters::service()]`. - Target: `libraries/BoardCore/src/SerialLink.h:12 [UTF8-LF sha256=99373bc4c090e34324cfd2ae8c94d6666c227ad7e157a47fbd85125aac6c13a0; anchor=class SerialLink]`. @@ -5649,8 +6215,8 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Omitted/replaced code: `unchecked 16-byte ASCII parser, atoi commands, unconditional 9600-baud serial forwarding and 50 ms reply delay`. Disposition: **replaced**. Reason: Bounded framed COBS/CRC transport, typed commands, correlation and capability/version checks prevent overflow, ambiguity and blocking. - Omitted/replaced code: `blocking welcome/reset beeps, watchdog reboot loop and loop-coupled animation progress`. Disposition: **replaced**. Reason: Queued Timer1 audio, explicit reset/update owners and monotonic wall-clock rendering keep keys, UART and LED frames responsive. - Omitted/replaced code: `commented confetti, sinelon, BPM and juggle examples`. Disposition: **not-applicable**. Reason: The user explicitly excluded these generic effects. -- Test-code evidence: `tests/led_grid_test.cpp:17 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=rainbowClockCadence]`. -- Test-code evidence: `tests/tone_player_test.cpp:141 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=kCueWelcome40Led]`. +- Test-code evidence: `tests/led_grid_test.cpp:33 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=rainbowClockCadence]`. +- Test-code evidence: `tests/tone_player_test.cpp:172 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=kCueWelcome40Led]`. - Test-code evidence: `tests/serial_link_test.cpp:45 [UTF8-LF sha256=c103ffb2c7530bb4c85373433b123fe072bb6649f2d97b82f70fa08d404eeae8; anchor=begin(38400]`. - Scoped advantage: Retains the donor's reusable progress, ownership and audio ideas with role-sized atomic scenes, nonblocking transport/audio and network-delay-independent animation instead of copying its incompatible pins and business rules. - Remaining: Physically verify actual grouped LED order/channel color, transition cadence, winner pulse/chase and the 590 ms welcome variation. Source and transport fixtures do not prove optical or audible behavior. @@ -5660,8 +6226,8 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `46158d60daaaac4d69b7c9deeb45a1118578f39c5ae1a4c8cf401e1b9726940f`. - Target: `firmware/avr/ShiftRegisters.cpp:55 [UTF8-LF sha256=990c51a59be04c29618232bd86f2857f9fddc65eb70d7585ad2f537c8a46b902; anchor=void ShiftRegisters::service()]`. -- Target: `firmware/avr/avr.ino:285 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=void resetGame(bool playWelcome = true)]`. -- Target: `firmware/esp/src/LedGrid.cpp:149 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=bool LedGrid::resetWhite()]`. +- Target: `firmware/avr/avr.ino:291 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=void resetGame(bool playWelcome = true)]`. +- Target: `firmware/esp/src/LedGrid.cpp:154 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=bool LedGrid::resetWhite()]`. - Retained: Complete semantic coverage used the fully read 1523-line base plus the complete two-hunk no-index diff; every unchanged line inherits the reviewed base decision and every changed line was reviewed separately. No file region was omitted. - Retained: The variant's direct start/clear/door mapping, disabled 74HC165 scan and zero-event suppression are modeled as explicit hardware-profile decisions rather than silently changing a shared loop. - Retained: Current first-wins reset is idempotent, restores the complete grouped scene, reports the zero winner/reset revision and plays no reset cue when already ready; this is more reliable than suppressing zero notifications or beeping solely because a cached value changed. @@ -5670,7 +6236,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Omitted/replaced code: `unconditional changed-value beep`. Disposition: **replaced**. Reason: Audio follows semantic winner/reset/menu cues and Silent/Prog policy. Idempotent ready-to-ready reset intentionally does not replay a melody. - Omitted/replaced code: `all unchanged FINAL_TABLE/RADIOGRAM business, incompatible pins, ASCII bridge and blocking effects inherited from 40LED.ino`. Disposition: **not-applicable**. Reason: The complete diff proves these regions are unchanged; their explicit base-file exclusion/replacement reasons apply identically rather than being silently omitted. - Test-code evidence: `tests/key_input_order_test.cpp:22 [UTF8-LF sha256=fc1b99ef668cae2692a3317df5c345b544f702207f85646b5e88871c0ce2292a; anchor=inputs.setVirtualInput]`. -- Test-code evidence: `tests/led_grid_test.cpp:467 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=grid.resetWhite()]`. +- Test-code evidence: `tests/led_grid_test.cpp:21 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=grid.resetWhite()]`. - Scoped advantage: Makes hardware differences explicit and preserves reset-to-zero truth instead of disabling inputs and suppressing the most important state transition inside a near-duplicate sketch. - Remaining: Physically verify the selected four-box input profile, zero-latency winner/reset reporting and idempotent-audio behavior on the newly connected ESP/AVR pair. @@ -5696,16 +6262,16 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `firmware/avr/BoardSettings.h:12 [UTF8-LF sha256=6bd5587eea26e1fbf0f1b5b827a5c5fc22b26bffb1bcf7cde8fcd2384d37946e; anchor=uint8_t flags = 2]`. - Target: `libraries/BoardCore/src/pccontroller/Keys.cpp:19 [UTF8-LF sha256=d9b8c8d4ec6beef667c01c5b5e3af539ead2429393e2632c4c59461f30c14224; anchor=void Key::updateSample]`. -- Target: `firmware/esp/src/LedGrid.cpp:284 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::service(uint32_t now)]`. -- Target: `firmware/avr/TonePlayer.cpp:61 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=void TonePlayer::update(uint32_t now)]`. +- Target: `firmware/esp/src/LedGrid.cpp:295 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::service(uint32_t now)]`. +- Target: `firmware/avr/TonePlayer.cpp:62 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=void TonePlayer::update(uint32_t now)]`. - Retained: Read all 623 lines through EOF. Persistent silent/configuration intent, single/hold input lifecycle, runtime color targets with damped transitions, tone commands, status reporting and explicit on/off ownership map to board settings, shared key events, LED-grid state and nonblocking audio. - Omitted/replaced code: `power-supply handle/PS_ON and two-item local menu business`. Disposition: **not-applicable**. Reason: Those rules belong to a power-controller product, not table-selection or first-wins. Pakar exposes role-specific controls and settings only after capability acknowledgement. - Omitted/replaced code: `A6/A4/A3/A2 inputs, D5/D6/D9 RGB and D7 buzzer mapping`. Disposition: **replaced**. Reason: They conflict with the confirmed Pakar shift-register, TM1637, buzzer, ISP and ninth-button profile. Hardware ownership comes from BoardPins and the selected role. - Omitted/replaced code: `dynamic String/strdup parser, 512-byte reservation, fixed 57600 baud and per-byte delay`. Disposition: **replaced**. Reason: Bounded COBS/CRC at 38,400 owns ESP/AVR traffic; validated structured commands avoid heap fragmentation, command ambiguity and loop stalls. - Omitted/replaced code: `blocking beep/menu/power delays and per-transition EEPROM.write`. Disposition: **replaced**. Reason: Tone/effects/input remain cooperative, and validated settings are coalesced/persisted only after explicit changes. -- Test-code evidence: `tests/pc_contract_test.cpp:116 [UTF8-LF sha256=bce8d58cf8ff4f2ef97d82aeea929443e1c25ffa2ff1be9ab37c684587e7cab7; anchor=silentSettings.flags]`. -- Test-code evidence: `tests/led_grid_test.cpp:26 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=First frame has no synthetic +3 jump.]`. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/pc_contract_test.cpp:119 [UTF8-LF sha256=d8aef9e99392072845e5110ecc9be0421435b0988daff633e1d7ec0da66bdc1c; anchor=silentSettings.flags]`. +- Test-code evidence: `tests/led_grid_test.cpp:42 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=First frame has no synthetic +3 jump.]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. - Scoped advantage: Keeps the reusable interaction/configuration ideas while making them role-aware, bounded, nonblocking and compatible with the actual board pins and framed peer link. - Remaining: Physical button, audio, color-transition and settings-persistence acceptance remain open on the newly connected four-box hardware. @@ -5729,7 +6295,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `firmware/avr/ShiftRegisters.cpp:55 [UTF8-LF sha256=990c51a59be04c29618232bd86f2857f9fddc65eb70d7585ad2f537c8a46b902; anchor=void ShiftRegisters::service()]`. - Target: `libraries/BoardCore/src/pccontroller/Keys.cpp:19 [UTF8-LF sha256=d9b8c8d4ec6beef667c01c5b5e3af539ead2429393e2632c4c59461f30c14224; anchor=void Key::updateSample]`. - Target: `libraries/BoardCore/src/SerialLink.h:14 [UTF8-LF sha256=99373bc4c090e34324cfd2ae8c94d6666c227ad7e157a47fbd85125aac6c13a0; anchor=explicit SerialLink(HardwareSerial &serial)]`. -- Target: `firmware/avr/TonePlayer.cpp:61 [UTF8-LF sha256=5ed8cc8ff5e68c01d86da2df01257715ec33299c90cc25fe8b09bd88e317631b; anchor=void TonePlayer::update(uint32_t now)]`. +- Target: `firmware/avr/TonePlayer.cpp:62 [UTF8-LF sha256=c9397c220a51caac48ea06e6b700a74ee3b5cfd98f1eda5e3e4288274ea6f2e6; anchor=void TonePlayer::update(uint32_t now)]`. - Retained: Read all 468 lines through EOF. Active-low relay register ownership, changed-state writes, finite blink intent, 15 ms input stabilization, silent audio, watchdog servicing and bidirectional front-panel communication map to shared shift registers, keys, settings, audio and framed hardware UART. - Omitted/replaced code: `SoftwareSerial panel on D10/D11 with raw byte forwarding`. Disposition: **replaced**. Reason: The user requires AVR HardwareSerial. D10-D13 participate in the reviewed ISP/profile, while the 38,400 COBS/CRC link preserves message boundaries, identity and acknowledgements. - Omitted/replaced code: `DS1307 Wire/RTC initialization`. Disposition: **not-applicable**. Reason: No I2C RTC is connected. A4/A5 belong to the confirmed TM1637 front panel and network time is owned by ESP when that role enables networking. @@ -5737,7 +6303,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Omitted/replaced code: `blocking flash/beep helpers and 500 ms blink`. Disposition: **replaced**. Reason: Key/UART service cannot pause for presentation. Current cues and effects use nonblocking deadlines and changed-state output commits. - Test-code evidence: `tests/key_input_order_test.cpp:31 [UTF8-LF sha256=fc1b99ef668cae2692a3317df5c345b544f702207f85646b5e88871c0ce2292a; anchor=inputs.setActiveOutputs(0xa5)]`. - Test-code evidence: `tests/serial_link_test.cpp:31 [UTF8-LF sha256=c103ffb2c7530bb4c85373433b123fe072bb6649f2d97b82f70fa08d404eeae8; anchor=Nested TX cannot overwrite RX.]`. -- Test-code evidence: `tests/tone_player_test.cpp:39 [UTF8-LF sha256=986f729611a759fe602f8eaee0bf1312d598a7bbd7209209c1b5df2c0346f513; anchor=player.update(start+270)]`. +- Test-code evidence: `tests/tone_player_test.cpp:40 [UTF8-LF sha256=2ab3f2e840d3b62a25f7da2a5bcdfb9b4c887eb70d022c7691649251049c650f; anchor=player.update(start+270)]`. - Scoped advantage: Consolidates duplicated raw panel/relay code into the actual hardware profile with nonblocking services and framed full-duplex UART. - Remaining: The staged four-box AVR still needs flash/readback plus measured UART/key/relay/audio behavior on its actual wiring. @@ -5747,7 +6313,7 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte - Target: `firmware/avr/BoardPins.h:44 [UTF8-LF sha256=9ad77cc5c97f95ff454bdde192d8c09376f6c82d37e4d3861541838c0c8acb05; anchor=constexpr uint8_t NinthButton = PIN_PD6]`. - Target: `firmware/avr/ShiftRegisters.cpp:55 [UTF8-LF sha256=990c51a59be04c29618232bd86f2857f9fddc65eb70d7585ad2f537c8a46b902; anchor=void ShiftRegisters::service()]`. -- Target: `firmware/avr/avr.ino:312 [UTF8-LF sha256=9d613a8d43be99a49bb627f427ab5fa2587c678b68b489b52e3e78983fac6f25; anchor=void chooseWinner(uint8_t button)]`. +- Target: `firmware/avr/avr.ino:318 [UTF8-LF sha256=3e2e53d2f972d36b109ce5c0d9b9651cba22126b7c36a72273444d859058c7d5; anchor=void chooseWinner(uint8_t button)]`. - Target: `libraries/BoardCore/src/SerialLink.h:25 [UTF8-LF sha256=99373bc4c090e34324cfd2ae8c94d6666c227ad7e157a47fbd85125aac6c13a0; anchor=void service()]`. - Retained: Read all 447 lines through EOF. Eight active-low inputs, separate manual/automatic relay ownership, changed-state relay reporting, watchdog recovery, a finite nonblocking beep deadline and reset/audio/relay commands map to the first-wins AVR role and framed peer contract. - Omitted/replaced code: `A6/A7 ninth/eighth analog threshold inputs with INPUT_PULLUP`. Disposition: **replaced**. Reason: ATmega328P ADC6/ADC7 are analog-only and cannot supply the requested reliable internal pull-up. Per user direction, keys 1-8 use the input register and key 9 uses spare AVR D6/PD6 with INPUT_PULLUP. @@ -5790,13 +6356,13 @@ Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte Verdict: **scoped-improvement**. Revision: `filesystem-snapshot`. Original-byte SHA-256: `df59cde813609ef2fad421583ed596697a8a888e2a090fba2da476f4f37a4ffa`. -- Target: `firmware/esp/src/LedGrid.cpp:284 [UTF8-LF sha256=b911387e191d6e063929abe86bbacc548bf0d5e5f5932cee150d273153a69251; anchor=void LedGrid::service(uint32_t now)]`. +- Target: `firmware/esp/src/LedGrid.cpp:295 [UTF8-LF sha256=b45b96382638eec98d7fb8f01c91d6f52f41bca35e65dc2bb45a3e3071ad4168; anchor=void LedGrid::service(uint32_t now)]`. - Target: `libraries/BoardCore/src/Tasks.cpp:7 [UTF8-LF sha256=55aac682e670f2aa62832818c60d45d508c85729aca242ff306439e9f4a1fb8b; anchor=void Tasks::update(uint32_t now)]`. - Retained: Read all 51 lines. Finite timed indication and automatic stop intent are retained through rollover-safe cooperative effect/service deadlines. - Omitted/replaced code: `direct legacy TCCR2/TIMSK register ISR toggling PD4`. Disposition: **replaced**. Reason: It lacks an OCR2 compare value, aliases application pins and is not portable to the ATmega328P/S3 split. Current finite effects are cooperative, role-owned and do not steal AVR Timer2. - Omitted/replaced code: `hard-coded four toggles at an unspecified interrupt period`. Disposition: **replaced**. Reason: Pakar effects use named durations and wall-clock deadlines, while audio keeps its own timer ownership. - Test-code evidence: `tests/tasks_test.cpp:67 [UTF8-LF sha256=1fc35f2e57d21d42048e392a28bdb09c123d7cf84a3a4c9a45c03534dc19977e; anchor=reset-owner cancellation passed]`. -- Test-code evidence: `tests/led_grid_test.cpp:222 [UTF8-LF sha256=5f66f9e729268b757b5423717baafd60e7128500040da68ef5fd04a060253b51; anchor=partial stream never auto-shows]`. +- Test-code evidence: `tests/led_grid_test.cpp:274 [UTF8-LF sha256=3de14e766bd50806bd5d220cba111bd351400401b11fff0dbae32f16bd9febd9; anchor=partial stream never auto-shows]`. - Scoped advantage: Cooperative rollover-safe deadlines preserve finite indication without stealing Timer2 or an application GPIO. - Remaining: Optical timing acceptance remains physical-board work. @@ -5827,7 +6393,7 @@ Verdict: **parity-gap**. Revision: `filesystem-snapshot-no-git`. Original-byte S - Target: `desktop/src/ui/cell_search.rs:47 [UTF8-LF sha256=735f49c2159d9eeb61f08a24e41bace6242115a722ad58abcd9463ec54a3413e; anchor=super::digits::latin(query)]`. - Target: `assets/web/rayanlamp.js:4 [UTF8-LF sha256=56c1f07d95c20925773552af89e6a768b2abf15796fb4911568854b10c6180ac; anchor=function latinDigits]`. - Target: `mobile/lib/cell_search.dart:7 [UTF8-LF sha256=cbb3c3837c0cac5dfc943f8765b5e28145df660dcc6be81ad40dd8e2ff378bb2; anchor=String latinDigits]`. -- Target: `mobile/lib/device_settings_page.dart:670 [UTF8-LF sha256=1d618c036930215ab12bc3a827625eef5819a3969a7663615115a65ba8519c27; anchor=int.tryParse(latinDigits(value))]`. +- Target: `mobile/lib/device_settings_page.dart:677 [UTF8-LF sha256=b187bcc1ed640dc8b7052ce03c4f36a840289396f8908dbc77b967190eb9e9ee; anchor=int.tryParse(latinDigits(value))]`. - Retained: Actual Persian/Arabic-Indic-to-ASCII maps adapted into Rust, JavaScript and Dart and invoked by native/Web/mobile cell search and actual mobile port/telemetry fields. Canonical IDs/ranges stay ASCII; peer names, secrets and protocol identifiers are not globally transformed. - Omitted/replaced code: `ShapePersianDigits visual decimal shaping`. Disposition: **pending**. Reason: Search needs ASCII normalization now. Apply visual shaping only to audited application-owned numeric labels, not arbitrary peer text or wire IDs. Display consumers remain pending. - Test-code evidence: `tests/rayanlamp_reuse_test.mjs:28 [UTF8-LF sha256=a0cf1105ee49c04fe068a63b03fa0725bf0b137c6807e0b166d15b5ca7d384af; anchor=latinDigits('۰۱۲۳۴۵۶۷۸۹]`. @@ -5852,16 +6418,16 @@ Verdict: **parity-gap**. Revision: `filesystem-snapshot-no-git`. Original-byte S Verdict: **parity-gap**. Revision: `filesystem-snapshot-no-git`. Original-byte SHA-256: `3a3b4f13d0c839664edc2b15bb4418254463ff4d36016cb798a799016ef2890a`. -- Target: `firmware/esp/src/ShellTui.h:87 [UTF8-LF sha256=43269b4efd622253cbf664f6d345ceb4c13bb5a72ae2c208c48277ee1d9aa6dc; anchor=if(editing_)return edit(ch,snapshot,action);]`. -- Target: `firmware/esp/src/ShellTuiBinding.h:3 [UTF8-LF sha256=6f60f8f299f835d901e1ba510bfa13eadb7a3ed31c68c602e3b548fe780a6a2b; anchor=struct ShellTuiSnapshot]`. +- Target: `firmware/esp/src/ShellTui.h:111 [UTF8-LF sha256=f1da4420e769c7a9da2570603ee70981c894808804d07cb3b8451e093f7d6efd; anchor=if(editing_)return edit(ch,snapshot,action);]`. +- Target: `firmware/esp/src/ShellTuiBinding.h:3 [UTF8-LF sha256=5c389a6579d59cadf6db2e921de6094652977811c3beb0ca118b1bf3d778561c; anchor=struct ShellTuiSnapshot]`. - Retained: formIsFiltering focused-field key ownership is adapted to a pure ShellTui editing state: printable input/Tab cannot invoke workspace shortcuts while a draft is active; Esc/Ctrl-C cancel without applying. - Retained: Readonly fields cannot emit Set, unchanged Enter is not a write, CR-LF submits once, and a draft is cancelled if its key/editable/secret authority changes. Observed refreshes do not repaint or replace an active draft. - Retained: One shared ShellProperty snapshot binds TUI fields to the same shell/configuration validators, rather than introducing a separate TUI business or persistence layer. - Omitted/replaced code: `Huh form types, Go interface assertions and Bubble Tea runtime`. Disposition: **replaced**. Reason: The microcontroller uses an allocation-free C++ editing state and explicit Action handoff. No Go/Huh/Bubble Tea runtime is ported or required, and no full Huh select-filter UI is claimed. - Omitted/replaced code: `Unicode/Persian input and desktop form widgets`. Disposition: **pending**. Reason: Current Arduino form accepts bounded printable ASCII only; terminal character shaping, Persian editing and richer host widgets require separate implementation and acceptance. -- Test-code evidence: `tests/shell_tui_test.cpp:37 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=private\trefresh]`. -- Test-code evidence: `tests/shell_tui_test.cpp:31 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=Async observation cannot repaint draft.]`. -- Test-code evidence: `tests/shell_tui_test.cpp:66 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=rows[0].editable=false]`. +- Test-code evidence: `tests/shell_tui_test.cpp:43 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=private\trefresh]`. +- Test-code evidence: `tests/shell_tui_test.cpp:37 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=Async observation cannot repaint draft.]`. +- Test-code evidence: `tests/shell_tui_test.cpp:72 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=rows[0].editable=false]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -5919,9 +6485,9 @@ Verdict: **parity-gap**. Revision: `filesystem-snapshot-no-git`. Original-byte S Verdict: **parity-gap**. Revision: `filesystem-snapshot-no-git`. Original-byte SHA-256: `3b56b9ee2bfa2e40b02e464c7cc2cd471d6dbb2148f06091bd56dfdf2926e1b3`. - Target: `firmware/esp/src/ShellWire.h:31 [UTF8-LF sha256=af3f23d908310dd349b0c90d1fc4385a212e6439b10c72f8dc2f37dace4b5216; anchor=class OutputWindow]`. -- Target: `firmware/esp/src/TelnetShell.h:17 [UTF8-LF sha256=20e75d8dd8f39bea93c0df3da5aff98635b857eefb55003cfebeabd4e5c92281; anchor=inline size_t terminalText]`. -- Target: `firmware/esp/src/ShellTui.h:10 [UTF8-LF sha256=43269b4efd622253cbf664f6d345ceb4c13bb5a72ae2c208c48277ee1d9aa6dc; anchor=class ShellTui]`. -- Target: `firmware/esp/src/ShellService.h:252 [UTF8-LF sha256=897ac8e49f95e1e2ebc8b59dbd1a76148d1fcaaecf609cf33d99d067fc849a0c; anchor=void closeDeviceShells()]`. +- Target: `firmware/esp/src/TelnetShell.h:18 [UTF8-LF sha256=d5ddb4296bba6cd7fbf0a928e613eeb1589c080d4e681db6e6fe2bda8de45345; anchor=inline size_t terminalText]`. +- Target: `firmware/esp/src/ShellTui.h:14 [UTF8-LF sha256=f1da4420e769c7a9da2570603ee70981c894808804d07cb3b8451e093f7d6efd; anchor=class ShellTui]`. +- Target: `firmware/esp/src/ShellService.h:382 [UTF8-LF sha256=e682a7089635b353b63c6b55718b973d3df8c99bec3b7de85ac576f6d6d08e5a; anchor=void closeDeviceShells()]`. - Target: `tools/device-shell.ps1:25 [UTF8-LF sha256=4e197e3502eb6ef40d2a3df7bdde97ed3c05140c97e06e3d07426232732a9c55; anchor=Exactly once; uncertain input is NEVER replayed.]`. - Retained: Session-fenced immutable input/output ownership is adapted into SerialChannel/OutputWindow and the host terminal tool: explicit Open/token, no uncertain input replay, nonwrapping sequences, output ACK before consumption and explicit close. - Retained: Terminal safety is adapted into bounded terminalText and TUI rendering: external CSI/OSC/DCS/control payloads cannot inject terminal actions, and only the project renderer emits ANSI; output/drafts/transcripts are bounded. @@ -5931,8 +6497,8 @@ Verdict: **parity-gap**. Revision: `filesystem-snapshot-no-git`. Original-byte S - Omitted/replaced code: `Transparent raw serial and automatic uncertain write retry`. Disposition: **replaced**. Reason: An additive boot/token-fenced COBS channel preserves ordinary USB recovery. Host terminal sends input once and does not reopen/replay actions automatically after uncertainty. - Test-code evidence: `tests/shell_wire_test.cpp:21 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=Lost read reply replays same bytes]`. - Test-code evidence: `tests/shell_wire_test.cpp:26 [UTF8-LF sha256=d3b4221251b6d150bdec3b9a9e78d3506e9d9c123b5699eef2919cc957da8562; anchor=output.sequence()==UINT16_MAX]`. -- Test-code evidence: `tests/shell_tui_test.cpp:14 [UTF8-LF sha256=b9920bdc69621d9e07b82909dbfa8e4a7a508c8868adf3b7cd50edc86302863e; anchor=never-render-this]`. -- Test-code evidence: `tests/telnet_shell_test.cpp:98 [UTF8-LF sha256=c0a3b2937c012535482557a093176894af3c6a600291839b0c38565afe8b45e1; anchor=Inspect the caller-owned backing bytes]`. +- Test-code evidence: `tests/shell_tui_test.cpp:19 [UTF8-LF sha256=7abbd0277e2a76e7804ab579c418321da3ab0dbd400faaec401d2c5c84d63f2a; anchor=never-render-this]`. +- Test-code evidence: `tests/telnet_shell_test.cpp:115 [UTF8-LF sha256=9554e3744bd58435e52bbc0879ebcb6fe24a965eb9db111e05812d7db8739b62; anchor=Inspect the caller-owned backing bytes]`. - Scoped advantage: Source comparison and hash-bound fixture references only; deployment, terminal interoperability and hardware acceptance remain pending. - Remaining: Compile and deploy the integrated shell, then prove simultaneous bounded Telnet sessions, AP/station/Ethernet listener and discovery changes, and framed USB recovery alongside normal protocol commands. - Remaining: Exercise authentication changes, disconnect/reconnect, slow-reader backpressure and maintenance exclusion on the actual board; an immediate OK/queued command is not proof of AVR execution or physical LED/display/audio success. @@ -5965,7 +6531,7 @@ Verdict: **equivalent-behavior**. Revision: `filesystem-snapshot-no-git`. Origin - Target: `assets/themes/browser-appearance.js:70 [UTF8-LF sha256=056ef41691b92dacbcd7c75b11ff38a2e492093e769aab26dc2577785994de54; anchor=function applyPrepaintLocale()]`. - Target: `tools/generate-web-appearance.mjs:35 [UTF8-LF sha256=e57a615bf5134b718d6df7b644e56faced0c0ef73e8662cf8cb06c05e23e59b4; anchor=replaceBlock('// BEGIN BROWSER CONTROLLER']`. -- Target: `firmware/esp/src/WebUi.h:9 [UTF8-LF sha256=612572e98bd3ff5f17b52a90856592751df35c34507706b60fb5a5828285b44a; anchor= diff --git a/mini-apps/snake/preflight.mjs b/mini-apps/snake/preflight.mjs new file mode 100644 index 00000000..f36db997 --- /dev/null +++ b/mini-apps/snake/preflight.mjs @@ -0,0 +1,12 @@ +// Read-only admission check: never starts a round, takes a lease or sends a tone. +import {Presenter} from './presenter.mjs'; +import {WebSocket} from '../../services/client-management/node_modules/ws/wrapper.mjs'; +const origin=process.argv[2]??'http://ledcontroller.local'; +const presenter=new Presenter({origin,Socket:WebSocket}); +try{ + const info=await presenter.connect(); + console.log(JSON.stringify({ok:true,...info,groups:presenter.groups.group_count, + buzzer:presenter.status.capabilities.acknowledged_features.includes('buzzer'), + presentation_revision:presenter.layer.revision,game_started:false,output_changed:false},null,2)); +}catch(error){console.error(error.message);process.exitCode=1;} +finally{await presenter.close();} diff --git a/mini-apps/snake/presenter.mjs b/mini-apps/snake/presenter.mjs new file mode 100644 index 00000000..cb0c8bf8 --- /dev/null +++ b/mini-apps/snake/presenter.mjs @@ -0,0 +1,111 @@ +// Uses Pakar's existing living presentation contract, identity and HTTP tooling. +import {randomBytes} from 'node:crypto'; +import {deviceOrigin,deviceRequest} from '../../tools/check-ethernet-live.mjs'; +import {boardPeer,toolHeaders,toolReport} from '../../tools/peer-identity.mjs'; +import {cues} from './sounds.mjs'; + +export class Presenter { + constructor({origin,request=deviceRequest,Socket,environment=process.env,now=()=>performance.now()}={}) { + this.origin=deviceOrigin(origin);this.request=request;this.Socket=Socket;this.now=now; + this.owner='snake-'+randomBytes(8).toString('hex');this.lease=null;this.uncertain=false;this.busy=false; + const user=environment.PAKAR_USERNAME,password=environment.PAKAR_PASSWORD; + if((user===undefined)!==(password===undefined))throw Error('Set both PAKAR_USERNAME and PAKAR_PASSWORD'); + this.headers=user===undefined?{}:{Authorization:'Basic '+Buffer.from(user+':'+password).toString('base64')}; + } + async read(path,options={}) { + const response=await this.request(this.origin,path,{...options,expectedAddress:this.peer?.address, + headers:{...this.headers,...(this.peer?{'X-Pakar-Board':this.peer.board,'X-Pakar-Boot':this.peer.boot}:{}),...options.headers}}); + if(response.status!==200)throw Error(`Board HTTP ${response.status}`); + const value=JSON.parse(response.body.toString('utf8')); + if(this.peer&&(value.board_id!==this.peer.board||value.boot_id!==this.peer.boot))throw Error('Board identity changed'); + return {value,remote:response.remote}; + } + async connect() { + const first=await this.read('/api/status');this.peer=boardPeer(first.value,first.remote); + const features=first.value.capabilities?.acknowledged_features??[]; + if(!features.includes('host-presentation')||!features.includes('table')|| + first.value.capabilities?.board_kind!=='table'||first.value.capabilities?.role!=='table-selection') + throw Error('Snake requires an acknowledged table-selection board with host presentation'); + this.status=first.value;this.observed=this.now(); + this.groups=(await this.read('/api/pixels/groups')).value; + if(this.groups.group_count!==80||!Number.isSafeInteger(this.groups.revision))throw Error('Expected 80 logical table cells'); + this.layer=(await this.read('/api/presentation')).value; + if(this.layer.active||this.layer.temporary||this.layer.persistent)throw Error('Existing presentation: refusing to replace it'); + if(!Number.isSafeInteger(this.layer.revision)||!Number.isSafeInteger(this.status.command_clock_ms))throw Error('Missing presentation revision/board clock'); + await this.openSocket();return this.info(); + } + async openSocket() { + if(!this.Socket)return; + await new Promise(resolve=>{ + const url=new URL('/ws',this.origin);url.protocol=url.protocol==='https:'?'wss:':'ws:'; + const ws=new this.Socket(url,{headers:toolHeaders({...this.headers,'X-Pakar-Board':this.peer.board,'X-Pakar-Boot':this.peer.boot}), + maxPayload:65536,handshakeTimeout:2500,lookup:(_host,options,cb)=>cb(null,options.all?[{address:this.peer.address,family:4}]:this.peer.address,4)}); + this.socket=ws;let finished=false; + const finish=()=>{if(!finished){finished=true;clearTimeout(timer);resolve();}}; + const timer=setTimeout(()=>{ws.terminate();finish();},3000); + ws.on('error',()=>{this.socketReady=false;finish();}); + ws.on('close',()=>{this.socketReady=false;if(this.pending){this.pending.reject(Error('Command outcome unknown; lease will expire'));this.pending=null;}finish();}); + ws.on('open',()=>{ws.send(JSON.stringify({type:'status'}));}); + ws.on('message',raw=>{ + try { + const value=JSON.parse(raw.toString()); + if(value.board_id&&value.boot_id&&(value.board_id!==this.peer.board||value.boot_id!==this.peer.boot))throw Error('WebSocket peer changed'); + if(this.pending&&value.request_id===this.pending.id){const p=this.pending;this.pending=null;p.resolve(value);return;} + if(value.http_peer_fencing===true){boardPeer(value,this.peer.address,this.peer);this.status=value;this.observed=this.now(); + if(!this.socketReady){this.socketReady=true;ws.send(JSON.stringify({type:'peer-introduction',report:toolReport()}));}finish();} + }catch{this.socketReady=false;ws.terminate();finish();} + }); + }); + } + info(){return {board_id:this.peer?.board,hostname:this.peer?.hostname,transport:this.socketReady?'WebSocket':'HTTP',active:!!this.lease,uncertain:this.uncertain};} + async send(fields,command='presentation') { + if(this.busy||this.uncertain)throw Error('Presentation busy or outcome unknown; no replay'); + this.busy=true; + try { + if(!this.socketReady||this.now()-this.observed>1500){ + const next=(await this.read('/api/status')).value;boardPeer(next,this.peer.address,this.peer);this.status=next;this.observed=this.now(); + } + const packet={type:'command',command,board_id:this.peer.board,boot_id:this.peer.boot, + request_id:randomBytes(16).toString('hex'),expires_at_ms:this.status.command_clock_ms+Math.floor(this.now()-this.observed)+8000, + ...(command==='presentation'?{revision:this.layer.revision,group_layout_revision:this.groups.revision,owner:this.owner, + lifetime:'temporary',ttl_ms:3000,space:'cells',composite:'replace',replace:true, + ...(this.lease?{lease_id:this.lease}:{})}:{}),...fields}; + if(Buffer.byteLength(JSON.stringify(packet))>4096)throw Error('Presentation exceeds command budget'); + if(command==='presentation')this.uncertain=true;let receipt; + if(this.socketReady){ + receipt=await new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{this.pending=null;reject(Error('Command outcome unknown; no HTTP replay'));},2200); + this.pending={id:packet.request_id,resolve:v=>{clearTimeout(timer);resolve(v);},reject:e=>{clearTimeout(timer);reject(e);}}; + try{this.socket.send(JSON.stringify(packet));}catch(error){this.pending=null;clearTimeout(timer);reject(error);} + }); + }else receipt=(await this.read('/api/command',{form:{packet:JSON.stringify(packet)}})).value; + if(receipt.board_id!==this.peer.board||receipt.boot_id!==this.peer.boot||receipt.request_id!==packet.request_id||receipt.accepted!==true) + throw Error(receipt.error??receipt.reason??'Command not acknowledged; no replay'); + if(command!=='presentation')return receipt; + if( + !Number.isSafeInteger(receipt.presentation?.revision)||receipt.presentation.revision<=this.layer.revision|| + (fields.action!=='clear'&&!(receipt.presentation.lease_id>0)))throw Error(receipt.error??'Presentation not acknowledged; no replay'); + this.layer=receipt.presentation;this.lease=fields.action==='clear'?null:this.layer.lease_id;this.uncertain=false; + return this.info(); + } finally {this.busy=false;} + } + frame(entries){return this.send({entries});} + renew(){return this.send({entries:[],replace:false});} + async sound(name){ + if(!cues[name])throw Error('Unknown sound cue'); + if(!this.status.capabilities?.acknowledged_features?.includes('buzzer'))throw Error('Board buzzer is not acknowledged'); + return this.send(cues[name],'buzzer'); + } + async close(){ + try{ + if(this.uncertain)return {restored:false,pending:true,message:'Presentation expires within 3 seconds of its last accepted frame'}; + if(this.lease){ + await this.send({action:'clear'}); + const current=(await this.read('/api/presentation')).value; + if(current.temporary)throw Error('Scene release not yet confirmed'); + return {restored:true,pending:false,message:'Game layer released; board scene restored'}; + } + return {restored:false,pending:false}; + }finally{this.socket?.close();this.socketReady=false;} + } +} diff --git a/mini-apps/snake/server.mjs b/mini-apps/snake/server.mjs new file mode 100644 index 00000000..cd64723c --- /dev/null +++ b/mini-apps/snake/server.mjs @@ -0,0 +1,114 @@ +// Removable mini-app. Run explicitly; opening /snake never contacts a board. +import http from 'node:http'; +import {readFile} from 'node:fs/promises'; +import {randomBytes,timingSafeEqual} from 'node:crypto'; +import {pathToFileURL} from 'node:url'; +import {Snake} from './engine.mjs'; +import {Presenter} from './presenter.mjs'; + +export async function createSnakeServer({port=0,origin='http://ledcontroller.local',enabled=true,PresenterClass=Presenter,Socket}={}) { + const game=new Snake(),token=randomBytes(24).toString('hex'),clients=new Set(); + let presenter=null,busy=false,error='',timer=null,closed=false,boardOutput=false,lastObserver=0; + let runId=0,soundEvent=null,soundId=0,boardSound=true,soundWarning='',restoration=null; + const snapshot=()=>({...game.snapshot(),enabled,error,boardOutput,board:presenter?.info()??null, + runId,soundEvent,boardSound,soundWarning,restoration}); + async function sound(name){ + soundEvent={id:++soundId,name};publish(); + if(presenter&&boardSound){try{await presenter.sound(name);soundWarning='';} + catch(e){soundWarning=e.message;}} + } + async function release(){const current=presenter;presenter=null; + if(current)try{restoration=await current.close();} + catch(e){restoration={restored:false,pending:true,message:'Release unconfirmed; temporary layer will expire'};throw e;}} + const publish=()=>{const text='data: '+JSON.stringify(snapshot())+'\n\n';for(const client of clients){ + if(client.writableLength>65536){clients.delete(client);client.destroy();lastObserver=Date.now();} + else client.write(text); + }}; + const schedule=()=>{clearTimeout(timer);if(closed||!['running','paused'].includes(game.phase))return; + timer=setTimeout(async()=>{ + if(busy){schedule();return;}busy=true; + try { + // Loss of every controller ends the host lease, never runs forever unattended. + if(!clients.size&&Date.now()-lastObserver>3000){await stop();return;} + const previousScore=game.score,previousPhase=game.phase; + if(game.phase==='running')game.step(); + if(presenter){if(game.phase==='paused')await presenter.renew();else await presenter.frame(game.frame());} + if(['over','won'].includes(game.phase)&&previousPhase!==game.phase)await sound(game.phase); + else if(game.score>previousScore)await sound('eat'); + if(['over','won'].includes(game.phase))await release(); + }catch(e){error=e.message;game.command('stop');await release().catch(()=>{});} + finally{busy=false;publish();schedule();} + },game.phase==='paused'?900:game.options.interval); + }; + async function stop(){clearTimeout(timer);game.command('stop');await release();} + async function command(body){ + if(!enabled)throw Error('Snake disabled for this host'); + if(body.action==='stop'&&body.runId!==undefined&&body.runId!==runId)throw Error('Retired game session'); + if(body.action==='direction'){game.turn(body.value);publish();return;} + if(busy)throw Error('Previous action is still completing');busy=true; + try{ + error=''; + if(body.action==='configure'){ + if(['running','paused'].includes(game.phase))throw Error('Stop before configuring'); + // Validate the complete draft before replacing either component. + const draft=new Snake();draft.configure(body.options??game.options);draft.setMap(body.map??game.map); + game.configure(draft.options);game.setMap(draft.map); + } + else if(body.action==='start'||body.action==='restart'){ + if(body.action==='restart')await stop(); + if(!['ready','stopped','over','won'].includes(game.phase))throw Error('Already active'); + // Output target can change only on an explicit Start/restart, never on open. + boardOutput=body.board===true;boardSound=body.boardSound!==false;restoration=null;soundWarning='';runId++; + if(boardOutput){presenter=new PresenterClass({origin,Socket});await presenter.connect();} + game.reset();game.command('start');if(presenter)await presenter.frame(game.frame());await sound('start');schedule(); + }else if(body.action==='stop')await stop(); + else {const before=game.phase;game.command(body.action,body.value);if(before!==game.phase&&['pause','resume'].includes(body.action))await sound(body.action);schedule();} + }catch(e){error=e.message;if(presenter?.uncertain||body.action==='start'||body.action==='restart'){game.command('stop');await release().catch(()=>{});}throw e;} + finally{busy=false;publish();} + } + const server=http.createServer(async(req,res)=>{ + try{ + const expected=`127.0.0.1:${server.address().port}`; + if(req.headers.host!==expected){res.writeHead(403).end();return;} + const url=new URL(req.url,`http://${expected}`); + res.setHeader('Cache-Control','no-store');res.setHeader('X-Content-Type-Options','nosniff'); + res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'"); + if(req.method==='GET'&&url.pathname==='/api/events'){ + if(clients.size>=8){res.writeHead(503).end();return;} + res.writeHead(200,{'Content-Type':'text/event-stream'});clients.add(res);lastObserver=Date.now();publish(); + req.on('close',()=>{clients.delete(res);lastObserver=Date.now();});return; + } + if(req.method==='GET'&&url.pathname==='/api/session'){res.writeHead(200,{'Content-Type':'application/json'}).end(JSON.stringify({token,...snapshot()}));return;} + if(req.method==='POST'&&url.pathname==='/api/snake'){ + const supplied=Buffer.from(String(req.headers['x-snake-token']??'')),secret=Buffer.from(token); + if(supplied.length!==secret.length||!timingSafeEqual(supplied,secret)||req.headers.origin&&req.headers.origin!==`http://${expected}`){res.writeHead(403).end();return;} + let text='';for await(const part of req){text+=part;if(text.length>8192){res.writeHead(413).end();return;}} + await command(JSON.parse(text));res.writeHead(200,{'Content-Type':'application/json'}).end(JSON.stringify(snapshot()));return; + } + const assets={'/snake':'index.html','/snake/':'index.html','/':'index.html','/app.mjs':'app.mjs','/sounds.mjs':'sounds.mjs','/style.css':'style.css'}; + if(req.method!=='GET'||!assets[url.pathname]){res.writeHead(404).end();return;} + res.writeHead(200,{'Content-Type':url.pathname.endsWith('.mjs')?'text/javascript':url.pathname.endsWith('.css')?'text/css':'text/html; charset=utf-8'}); + res.end(await readFile(new URL(assets[url.pathname],import.meta.url))); + }catch(e){if(!res.headersSent)res.writeHead(400,{'Content-Type':'application/json'});res.end(JSON.stringify({error:e.message}));} + }); + server.requestTimeout=5000; + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',resolve);}); + return {url:`http://127.0.0.1:${server.address().port}/snake`,token,snapshot, + async close(){closed=true;clearTimeout(timer);await stop().catch(()=>{});for(const c of clients)c.end();await new Promise(resolve=>server.close(resolve));}}; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ + try{ + const options={port:18490}; + const args=process.argv.slice(2); + for(let i=0;iapp.close().finally(()=>process.exit())); + }catch(e){console.error(e.message);process.exitCode=1;} +} diff --git a/mini-apps/snake/sounds.mjs b/mini-apps/snake/sounds.mjs new file mode 100644 index 00000000..dd187aba --- /dev/null +++ b/mini-apps/snake/sounds.mjs @@ -0,0 +1,32 @@ +// Shared, finite game cues. Browser and AVR use the same pitch and duration. +// No tone is emitted merely by opening the launcher or reconnecting an observer. +export const cues=Object.freeze({ + start:{frequency:659,duration_ms:100}, + eat:{frequency:1047,duration_ms:65}, + over:{frequency:220,duration_ms:180}, + won:{frequency:1319,duration_ms:180}, + pause:{frequency:440,duration_ms:55}, + resume:{frequency:880,duration_ms:55}, +}); + +export class GameAudio { + constructor(){this.context=null;this.enabled=true;} + async unlock(){ + const Audio=globalThis.AudioContext??globalThis.webkitAudioContext; + if(!Audio)return false; + this.context??=new Audio(); + await this.context.resume();return this.context.state==='running'; + } + play(name){ + const cue=cues[name],audio=this.context; + if(!this.enabled||!cue||audio?.state!=='running')return; + const start=audio.currentTime,end=start+cue.duration_ms/1000; + const oscillator=audio.createOscillator(),gain=audio.createGain(); + oscillator.type='sine';oscillator.frequency.value=cue.frequency; + gain.gain.setValueAtTime(0,start);gain.gain.linearRampToValueAtTime(.12,start+.006); + gain.gain.setValueAtTime(.12,Math.max(start+.006,end-.025));gain.gain.linearRampToValueAtTime(0,end); + oscillator.connect(gain);gain.connect(audio.destination); + oscillator.onended=()=>{oscillator.disconnect();gain.disconnect();}; + oscillator.start(start);oscillator.stop(end+.005); + } +} diff --git a/mini-apps/snake/style.css b/mini-apps/snake/style.css new file mode 100644 index 00000000..1a7c28de --- /dev/null +++ b/mini-apps/snake/style.css @@ -0,0 +1,10 @@ +:root{color-scheme:dark;--bg:#0d131a;--surface:#18222d;--line:#2c3b49;--ink:#e9f1f7;--muted:#95a8b8;--green:#51e8ab;font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at top,#18352f55,transparent 65%),var(--bg);color:var(--ink);min-height:100svh}main{max-width:1400px;margin:auto;padding:clamp(16px,3vw,40px)}header{display:flex;gap:14px;align-items:center;margin-bottom:26px}h1,h2,p{margin:0}h1{font-size:24px;letter-spacing:-.5px}h2{font-size:18px;margin-bottom:18px}header p,.hint,.attribution,#draft{color:var(--muted);font-size:13px}.mark{height:48px;width:48px;display:grid;place-items:center;background:linear-gradient(135deg,#51e8ab,#2597a6);color:#10221c;border-radius:15px;font-size:38px;font-weight:800}.pill{margin-left:auto;border:1px solid #51e8ab44;background:#51e8ab14;color:var(--green);border-radius:30px;padding:5px 13px}button,select,input[type=number],.file{font:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:10px;min-height:40px;padding:8px 14px}button,.file{cursor:pointer;transition:background .15s,border-color .15s,opacity .15s}button:hover:not(:disabled),.file:hover{border-color:#51e8ab99;background:#203a36}button:active:not(:disabled){background:#284e43}button:focus-visible,select:focus-visible,input:focus-visible,canvas:focus-visible{outline:2px solid var(--green);outline-offset:3px}button:disabled{opacity:.38;cursor:default}.primary{background:var(--green);color:#09271b;font-weight:650;border-color:var(--green)}button.primary:hover:not(:disabled){background:#84f3c4;color:#09271b}.toolbar,.bottom,.metrics,.mapbar,footer{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.target{margin-left:auto}label{display:flex;align-items:center;gap:9px}input[type=checkbox]{accent-color:var(--green);width:18px;height:18px}.metrics{margin:20px 0;color:var(--muted)}.metrics span{display:flex;gap:10px}.metrics strong{color:var(--ink);font-variant-numeric:tabular-nums}.metrics #connection{margin-left:auto;font-size:13px}.arena{border:1px solid var(--line);border-radius:20px;padding:14px;background:linear-gradient(160deg,#182530,#111a22);box-shadow:0 18px 70px #0003;overflow:hidden}canvas{display:block;width:100%;height:auto;touch-action:none;border-radius:10px}.bottom{margin:22px 0}.pad{display:flex;gap:6px;margin-right:auto}.pad button,#settingsToggle{width:42px;height:42px;padding:0;font-size:22px}.settings{background:var(--surface);border:1px solid var(--line);padding:24px;border-radius:18px;margin:24px 0}.fields{display:flex;flex-wrap:wrap;gap:20px;margin-bottom:24px}.fields label{align-items:center}.fields input[type=number]{width:90px;background:var(--bg)}.fields span{display:flex;align-items:center;gap:8px}.mapbar{padding-bottom:22px}select{max-width:230px}.file input{display:none}footer{justify-content:space-between;min-height:40px}.attribution{margin-top:24px}#error{border:1px solid #e9aa5566;background:#e9aa5514;color:#ffd9a4;padding:12px 16px;border-radius:12px;margin-bottom:16px}.editing{border-color:var(--green)!important}[hidden]{display:none!important}@media(max-width:650px){main{padding:16px}header{margin-bottom:18px}.toolbar{gap:8px}.toolbar button{flex:1;padding:8px}.target{width:100%;margin:8px 0 0}.arena{padding:5px;border-radius:12px}.hint{display:none}.fields{gap:14px}.fields label{flex:1 1 160px}.settings{padding:16px}.mapbar label{width:100%}.mapbar select{flex:1}.attribution{font-size:11px}.bottom{gap:8px}.bottom label{font-size:13px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}} +:root{--steer-size:58px} +.playfield{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:24px;align-items:center} +.pad{display:grid;grid-template-columns:repeat(3,var(--steer-size));grid-template-rows:repeat(3,var(--steer-size));gap:7px;margin:0;touch-action:none;user-select:none;-webkit-user-select:none} +.pad button{width:var(--steer-size);height:var(--steer-size);min-height:0;padding:0;border-radius:16px;font-size:30px;touch-action:none;-webkit-tap-highlight-color:transparent} +.pad [data-direction=up]{grid-area:1/2}.pad [data-direction=left]{grid-area:2/1}.pad [data-direction=right]{grid-area:2/3}.pad [data-direction=down]{grid-area:3/2} +.pad button.current{border-color:#51e8ab88;background:#183e32;color:#80f4bf} +#heading{grid-area:2/2;display:grid;place-items:center;color:var(--muted);font-size:24px} +.bottom{justify-content:space-between}.arena{min-width:0} +@media(max-width:850px){.playfield{grid-template-columns:minmax(0,1fr);gap:16px}.pad{justify-self:center;--steer-size:60px}} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 63e1e06a..140850f0 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -4,11 +4,15 @@ + + + + - + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/HostWifiChannel.kt b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/HostWifiChannel.kt new file mode 100644 index 00000000..8ec4f60e --- /dev/null +++ b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/HostWifiChannel.kt @@ -0,0 +1,131 @@ +package ir.digitalogic.pakar.pakarcontroller + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiNetworkSpecifier +import android.net.wifi.WifiNetworkSuggestion +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** Explicit host-side consent only. Never changes ESP settings or verifies a peer. + * Reuses the donor DiscoveryChannel's main-loop/callback/close ownership pattern. + */ +class HostWifiChannel(private val activity: Activity, messenger: BinaryMessenger) { + private val channel = MethodChannel(messenger, "ir.digitalogic.pakar/host-wifi") + private val cm = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val main = Handler(Looper.getMainLooper()) + private var callback: ConnectivityManager.NetworkCallback? = null + private var bound: Network? = null + private var pending: MethodChannel.Result? = null + private var permissionCall: MethodCall? = null + private var saving = false + private val permissionCode = 3114 + private val saveCode = 3115 + init { channel.setMethodCallHandler { call, result -> + try { + when (call.method) { + "settings" -> { activity.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)); result.success(null) } + "release", "cancel" -> { release(); complete("cancelled"); permissionCall = null; result.success(null) } + "join", "save" -> { + check(pending == null && !saving) { "Finish the current Wi-Fi request first" } + val ssid = call.argument("ssid") ?: "" + val password = call.argument("password") ?: "" + val open = call.argument("open") == true + require(ssid.toByteArray(Charsets.UTF_8).size in 1..32 && ssid.none { it.code < 32 || it.code == 127 }) { "Invalid Wi-Fi name" } + require(open || password.length in 8..63 && password.all { it.code in 32..126 }) { "Use an 8–63 character WPA2 password" } + if (call.method == "save") { + check(Build.VERSION.SDK_INT >= 30) { "Saving networks requires Android 11. Use Wi-Fi settings instead." } + val suggestion = WifiNetworkSuggestion.Builder().setSsid(ssid).apply { + if (!open) setWpa2Passphrase(password) + }.build() + val intent = Intent(Settings.ACTION_WIFI_ADD_NETWORKS).putParcelableArrayListExtra( + Settings.EXTRA_WIFI_NETWORK_LIST, arrayListOf(suggestion)) + pending = result; saving = true + activity.startActivityForResult(intent, saveCode) + } else { + check(Build.VERSION.SDK_INT >= 29) { "Local joins require Android 10. Use Wi-Fi settings instead." } + pending = result + val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + permissionCall = call + activity.requestPermissions(arrayOf(permission), permissionCode) + } else join(call) + } + } + else -> result.notImplemented() + } + } catch (_: Exception) { + if (pending === result) { pending = null; permissionCall = null; saving = false } + result.error("wifi_unavailable", "Wi-Fi request unavailable. Check Wi-Fi and permissions, or use system Wi-Fi settings.", null) + } + } } + private fun join(call: MethodCall) { + release() + val spec = WifiNetworkSpecifier.Builder().setSsid(call.argument("ssid")!!).apply { + if (call.argument("open") != true) setWpa2Passphrase(call.argument("password")!!) + }.build() + val request = NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).setNetworkSpecifier(spec).build() + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { main.post { + if (callback !== this) return@post + try { + if (cm.bindProcessToNetwork(network)) { bound = network; complete("available") } + else { fail("Android could not route this app over Wi-Fi"); release() } + } catch (_: Exception) { fail("Android denied Wi-Fi routing"); release() } + } } + override fun onUnavailable() { main.post { + if (callback === this) { fail("Wi-Fi not available or request declined. Check the network name and password."); release() } + } } + override fun onLost(network: Network) { main.post { + if (callback === this) { release(); complete("cancelled") } + } } + } + callback = cb + try { cm.requestNetwork(request, cb, 45000) } + catch (_: Exception) { release(); fail("Wi-Fi permission or location service is unavailable. Use system Wi-Fi settings.") } + } + fun onRequestPermissionsResult(code: Int) { + if (code != permissionCode) return + val call = permissionCall ?: return + permissionCall = null + val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) { + try { join(call) } catch (_: Exception) { release(); fail("Wi-Fi join unavailable") } + } else fail("Wi-Fi permission declined. You can still join through system Wi-Fi settings.") + } + fun onActivityResult(code: Int, result: Int, data: Intent?): Boolean { + if (code != saveCode) return false + saving = false + if (result != Activity.RESULT_OK) complete("cancelled") + else { + val states = data?.getIntegerArrayListExtra(Settings.EXTRA_WIFI_NETWORK_RESULT_LIST) + if (states?.size == 1 && states[0] in listOf(Settings.ADD_WIFI_RESULT_SUCCESS, Settings.ADD_WIFI_RESULT_ALREADY_EXISTS)) complete("saved") + else fail("Android did not save the network. Use system Wi-Fi settings.") + } + return true + } + private fun complete(value: String) { val result = pending; pending = null; result?.success(value) } + private fun fail(message: String) { val result = pending; pending = null; result?.error("wifi_failed", message, null) } + private fun release() { + // Release only our process binding; never disconnect the system's Wi-Fi. + try { if (bound != null && cm.boundNetworkForProcess == bound) cm.bindProcessToNetwork(null) } + catch (_: Exception) { /* Android owns cleanup if the network is already gone. */ } + bound = null + val old = callback; callback = null + if (old != null) try { cm.unregisterNetworkCallback(old) } catch (_: Exception) {} + } + fun close() { release(); permissionCall = null; complete("cancelled"); channel.setMethodCallHandler(null) } +} diff --git a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt index bb403e0f..d071fa8d 100644 --- a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/MainActivity.kt @@ -13,6 +13,7 @@ import java.util.concurrent.Executors class MainActivity : FlutterActivity() { private var discovery: DiscoveryChannel? = null + private var hostWifi: HostWifiChannel? = null private var usb: UsbControllerChannel? = null private var ble: BleControllerChannel? = null private var toneMirror: ToneMirrorChannel? = null @@ -32,6 +33,7 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + hostWifi = HostWifiChannel(this, flutterEngine.dartExecutor.binaryMessenger) usb = UsbControllerChannel(this, flutterEngine.dartExecutor.binaryMessenger) discovery = DiscoveryChannel(this, flutterEngine.dartExecutor.binaryMessenger) ble = BleControllerChannel(this, flutterEngine.dartExecutor.binaryMessenger) @@ -102,6 +104,7 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (hostWifi?.onActivityResult(requestCode, resultCode, data) == true) return if (appUpdates?.onActivityResult(requestCode, resultCode, data) == true) return if (requestCode != pickCode && requestCode != createCode) return val result = pending ?: return @@ -160,9 +163,11 @@ class MainActivity : FlutterActivity() { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) ble?.onRequestPermissionsResult(requestCode) + hostWifi?.onRequestPermissionsResult(requestCode) } override fun onDestroy() { + hostWifi?.close(); hostWifi = null usb?.close() ble?.close(); ble = null toneMirror?.close(); toneMirror = null diff --git a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/SnakeLauncherActivity.kt b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/SnakeLauncherActivity.kt new file mode 100644 index 00000000..664c85b0 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/SnakeLauncherActivity.kt @@ -0,0 +1,25 @@ +package ir.digitalogic.pakar.pakarcontroller + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast + +/** External, removable game. ADB reverse can expose its loopback host to Android. + * Opening this activity cannot connect a board or start a round. */ +class SnakeLauncherActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.action == Intent.ACTION_VIEW && + intent.dataString == "pakarcontroller://mini-app/snake") { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("http://127.0.0.1:18490/snake"))) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, "Install a browser to open the Snake mini-app", Toast.LENGTH_LONG).show() + } + } + finish() + } +} diff --git a/mobile/lib/advanced_page.dart b/mobile/lib/advanced_page.dart new file mode 100644 index 00000000..127c59e4 --- /dev/null +++ b/mobile/lib/advanced_page.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import 'api.dart'; +import 'bridge_page.dart'; +import 'models.dart'; +import 'network_tools_page.dart'; +import 'workspace_widgets.dart'; + +/// Advanced destinations stay out of the main game controls. The app bridge +/// and the board's own outbound connections are distinct owners. +class AdvancedPage extends StatelessWidget { + const AdvancedPage({ + super.key, + required this.api, + required this.observed, + required this.openSettings, + }); + final PakarApi api; + final DeviceStatus? Function() observed; + final void Function(String section) openSettings; + + @override + Widget build(BuildContext context) { + void open(String title, Widget child) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + appBar: AppBar(title: Text(title)), + body: child, + ), + ), + ); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const WorkspaceHeader(title: 'Advanced', icon: Icons.tune), + const SizedBox(height: 16), + WorkspaceSection( + title: 'Board', + icon: Icons.developer_board, + children: [ + ListTile( + leading: const Icon(Icons.schedule), + title: const Text('Clock & network tools'), + subtitle: const Text('NTP, HTTP time, public IP, DNS and ping'), + trailing: const Icon(Icons.chevron_right), + onTap: observed()?.peer.has('network-tools') == true + ? () => open( + 'Clock & network tools', + NetworkToolsPage(api: api), + ) + : null, + ), + ListTile( + leading: const Icon(Icons.wifi), + title: const Text('Wi-Fi & Ethernet'), + subtitle: const Text( + 'Scan networks, station profiles and addressing', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => openSettings('wifi'), + ), + ListTile( + leading: const Icon(Icons.cloud_outlined), + title: const Text('Remote connections'), + subtitle: const Text('Board MQTT and WebSocket configuration'), + trailing: const Icon(Icons.chevron_right), + onTap: () => openSettings('mqtt'), + ), + ], + ), + const SizedBox(height: 16), + WorkspaceSection( + title: 'This application', + icon: Icons.phone_android, + children: [ + ListTile( + leading: const Icon(Icons.hub_outlined), + title: const Text('App bridge & repeater'), + subtitle: const Text('Local applications and remote endpoints'), + trailing: const Icon(Icons.chevron_right), + onTap: () => open( + 'App bridge & repeater', + BridgePage(api: api, observed: observed), + ), + ), + ], + ), + ], + ); + } +} diff --git a/mobile/lib/api.dart b/mobile/lib/api.dart index e0820fd7..35301955 100644 --- a/mobile/lib/api.dart +++ b/mobile/lib/api.dart @@ -140,6 +140,11 @@ class PakarApi { int _configurationRevision = 0; final _configurationUpdates = StreamController.broadcast(sync: true); Stream get configurationUpdates => _configurationUpdates.stream; + final _networkObservations = StreamController>.broadcast( + sync: true, + ); + Stream> get networkObservations => + _networkObservations.stream; void Function()? onTransportRetired; void Function(Map)? onPeripheralEvent; void Function()? onPeripheralStreamLost; @@ -241,6 +246,22 @@ class PakarApi { connectionMethod == ConnectionMethod.network; bool supportsRoute(String method, String path) { if (isUsb) { + if (_latestStatus?.peer.has('network-tools') == true && + (method == 'GET' + ? const { + '/api/network/tools', + '/api/time', + '/api/wifi/scan', + '/api/network', + } + : const { + '/api/network/tools', + '/api/network/probe', + '/api/network', + }) + .contains(path)) { + return true; + } return method == 'GET' ? {'/api/status', '/api/cells/mapping'}.contains(path) : { @@ -695,11 +716,33 @@ class PakarApi { String path, Map values, { required bool Function() authorize, + }) => _fastCommand(path, values, authorize: authorize); + + Future _fastCommand( + String path, + Map values, { + required bool Function() authorize, + void Function(Map)? receipt, }) async { if (!commandNames.containsKey(path)) return null; + // Preserve the existing dedicated USB cue path. These new WS commands + // require the AVR-correlated envelope, not the table-only USB opcode switch. + if (isUsb && + const ['/api/audio', '/api/buzzer', '/api/outputs'].contains(path)) { + return null; + } if (isUsb) return _usbCommand(path, values, authorize); var snapshot = _latestStatus; - if (snapshot?.peer.has('command-ack') != true) return null; + if (snapshot?.peer.has('command-ack') != true) { + if (path.startsWith('/api/minigame/')) { + throw const FormatException('Minigame command receipts unavailable'); + } + return null; + } + if (const ['/api/audio', '/api/buzzer', '/api/outputs'].contains(path) && + snapshot?.peer.has('avr-command-ack') != true) { + return null; + } if (path == '/api/game/reset' && snapshot?.peer.has('reset-ack') != true) { return null; } @@ -752,6 +795,7 @@ class PakarApi { ); _acceptIdentity(actual.json); _rememberStatus(actual); + receipt?.call(reply); return actual; } Completer?>? pending; @@ -862,6 +906,7 @@ class PakarApi { ); _acceptIdentity(actual.json); _rememberStatus(actual); + receipt?.call(reply); return actual; } finally { if (pending != null) { @@ -1400,6 +1445,24 @@ class PakarApi { authorize: () => owner == connectionRevision && connectionWanted, ); } + if (path == '/api/pixels/corrections') { + final owner = connectionRevision; + return usb.pixelCorrectionsApi( + authorize: () => owner == connectionRevision && connectionWanted, + ); + } + final uri = Uri.parse(path); + if (const { + '/api/network/tools', + '/api/time', + '/api/wifi/scan', + '/api/network', + }.contains(uri.path)) { + final owner = connectionRevision; + return usb.networkApi('GET', uri.path, { + if (uri.queryParameters['results'] == 'true') 'results': true, + }, authorize: () => owner == connectionRevision && connectionWanted); + } throw const FormatException( 'This read is unavailable through USB; no network fallback attempted', ); @@ -1425,6 +1488,76 @@ class PakarApi { Future> getJson(String path) => _get(path); + /// Same boot-fenced USB API or correlated WS-first command, with the existing + /// user-configurable HTTP fallback. No independent network/command owner. + Future> networkToolsWrite( + String path, + Map body, + ) async { + final owner = connectionRevision; + bool admitted() => + owner == connectionRevision && + connectionWanted && + _latestStatus?.peer.has('network-tools') == true; + if (isUsb) { + await _ensureUsb(); + final result = await usb.networkApi( + 'POST', + path, + body, + authorize: admitted, + ); + if (result['settings'] is Map && result['clock'] is Map) return result; + return usb.networkApi( + 'GET', + '/api/network/tools', + const {}, + authorize: admitted, + ); + } + Map? receipt; + final actual = await _fastCommand( + path, + { + if (path == '/api/network/tools') 'settings': jsonEncode(body), + if (path == '/api/network/probe') + ...{ + 'kind': '${body['kind']}', + if (body.containsKey('target')) 'target': '${body['target']}', + }, + }, + authorize: admitted, + receipt: (value) => receipt = value, + ); + if (actual == null || receipt?['network_tools'] is! Map) { + throw const FormatException( + 'Network operation receipt unavailable; inspect before retrying', + ); + } + final result = receipt!['network_tools'] as Map; + if (path == '/api/network/probe') { + final job = result['job']; + if (job is! Map || + job['kind'] != body['kind'] || + const {'dns', 'ping'}.contains(body['kind']) && + job['target'] != body['target']) { + throw const OperationUncertain( + 'The current diagnostic belongs to another request; no probe was replayed', + ); + } + } + if (path == '/api/network/tools') { + final saved = result['settings']; + if (saved is! Map || + !body.entries.every((entry) => saved[entry.key] == entry.value)) { + throw const OperationUncertain( + 'Saved network tools differ from the requested settings', + ); + } + } + return result; + } + /// Bounded single-dispatch maintenance transport. Even ISP probe uses this /// path: its GET resets AVR, so it must not be retried through discovery. Future maintenanceBytes( @@ -1607,6 +1740,36 @@ class PakarApi { return value; } + /// Same correlated WS/HTTP command owner as game actions. Older peers retain + /// their explicit single-dispatch HTTP lease endpoint; never retry a rejected + /// or uncertain preview through a different endpoint. + Future> previewCorrections( + Map configuration, { + required bool Function() authorize, + }) async { + const path = '/api/pixels/corrections/preview'; + final values = {'configuration': jsonEncode(configuration)}; + if (!isUsb && + freshStatus?.peer.has('pixel-corrections-preview-command') == true) { + Map? envelope; + final actual = await _fastCommand( + path, + values, + authorize: authorize, + receipt: (value) => envelope = value, + ); + if (actual == null || + envelope == null || + envelope!['pixel_corrections_preview'] is! Map) { + throw const OperationUncertain( + 'Correction preview receipt unavailable; no preview was replayed.', + ); + } + return {'preview': envelope!['pixel_corrections_preview']}; + } + return postJson(path, values, authorize: authorize); + } + /// Single dispatch, form BODY (never secret-bearing URL parameters), and /// caller-owned capability check after any awaited identity verification. Future> postJson( @@ -1615,7 +1778,67 @@ class PakarApi { bool Function()? authorize, }) async { _requireNetwork(); + if (isUsb && path == '/api/network') { + await _ensureUsb(); + final owner = connectionRevision; + const booleans = { + 'station_enabled', + 'ap_enabled', + 'dhcp', + 'station_open', + 'auto_switch', + }; + return usb.networkApi( + 'POST', + path, + { + for (final entry in values.entries) + entry.key: + booleans.contains(entry.key) && + const {'true', 'false'}.contains(entry.value) + ? entry.value == 'true' + : entry.value, + }, + authorize: () => + owner == connectionRevision && + connectionWanted && + authorize?.call() != false, + ); + } + if (path.startsWith('/api/minigame/')) { + final actual = await fastCommand( + path, + values, + authorize: authorize ?? () => true, + ); + if (actual == null) { + throw const FormatException('Minigame command receipt unavailable'); + } + return { + 'accepted': true, + 'board_id': actual.json['board_id'], + 'boot_id': actual.json['boot_id'], + 'status': actual.json, + }; + } if (isUsb) { + if (path == '/api/pixels/corrections' || + path == '/api/pixels/corrections/preview') { + await _ensureUsb(); + final owner = connectionRevision; + final configuration = jsonDecode(values['configuration'] ?? 'null'); + if (configuration is! Map) { + throw const FormatException('Invalid pixel correction configuration'); + } + return usb.pixelCorrectionsApi( + configuration: configuration, + preview: path.endsWith('/preview'), + authorize: () => + owner == connectionRevision && + connectionWanted && + authorize?.call() != false, + ); + } if (path == '/api/pixels/groups') { await _ensureUsb(); final owner = connectionRevision; @@ -1688,6 +1911,10 @@ class PakarApi { Map values = const {}, ]) async { _requireNetwork(); + if (path.startsWith('/api/minigame/')) { + await postJson(path, values); + return; + } if (isUsb) { await _ensureUsb(); if (freshStatus == null && !usb.busy) await status(); @@ -2101,6 +2328,16 @@ class PakarApi { } if (message is! String) continue; final value = jsonDecode(message); + if (value is Map && + value['type'] == 'network-observation') { + if (value['board_id'] == _verifiedBoard && + value['boot_id'] == _verifiedBoot && + _verifiedBoard != null && + !_networkObservations.isClosed) { + _networkObservations.add(value); + } + continue; + } if (value is Map && value['type'] == 'configuration-changed') { final changed = value['revision']; @@ -2585,6 +2822,7 @@ class PakarApi { _configurationRefresh?.cancel(); _configurationRefresh = null; unawaited(_configurationUpdates.close()); + unawaited(_networkObservations.close()); await bluetooth.close(); await usb.close(); _abortReads(); diff --git a/mobile/lib/color_picker.dart b/mobile/lib/color_picker.dart new file mode 100644 index 00000000..907fc351 --- /dev/null +++ b/mobile/lib/color_picker.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// Adapt Pealayer ColorPicker.tsx / ColorDropdown.tsx: one picker owns presets, +// custom editing and 12 deduplicated session recents. No preference/flash write. +const ledColorPalette = { + 'White': 0xffffff, + 'Red': 0xff0000, + 'Green': 0x00ff00, + 'Blue': 0x0000ff, + 'Amber': 0xffbf00, + 'Orange': 0xff8000, + 'Yellow': 0xffff00, + 'Lime': 0x80ff00, + 'Mint': 0x00ff80, + 'Cyan': 0x00ffff, + 'Azure': 0x0080ff, + 'Violet': 0x8000ff, + 'Magenta': 0xff00ff, + 'Rose': 0xff0080, + 'Pink': 0xff80bf, + 'Off': 0x000000, +}; +final _recentLedColors = []; +String rgbHex(int rgb) => rgb.toRadixString(16).padLeft(6, '0').toUpperCase(); +String rgbName(int rgb) => + ledColorPalette.entries + .where((entry) => entry.value == rgb) + .map((entry) => entry.key) + .firstOrNull ?? + '#${rgbHex(rgb)}'; + +Future showLedColorPicker( + BuildContext context, { + required int initial, + String title = 'Color', +}) async { + final result = await showDialog( + context: context, + builder: (_) => _ColorPicker(initial: initial, title: title), + ); + if (result != null) { + _recentLedColors.remove(result); + _recentLedColors.insert(0, result); + if (_recentLedColors.length > 12) _recentLedColors.removeLast(); + } + return result; +} + +class _ColorPicker extends StatefulWidget { + const _ColorPicker({required this.initial, required this.title}); + final int initial; + final String title; + @override + State<_ColorPicker> createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State<_ColorPicker> { + late HSVColor hsv = HSVColor.fromColor(Color(0xff000000 | widget.initial)); + late final hex = TextEditingController(text: rgbHex(widget.initial)); + bool valid = true; + int get rgb => hsv.toColor().toARGB32() & 0xffffff; + void setColor(int value) => setState(() { + hsv = HSVColor.fromColor(Color(0xff000000 | value)); + hex.text = rgbHex(value); + valid = true; + }); + void change(HSVColor value) => setState(() { + hsv = value; + hex.text = rgbHex(rgb); + valid = true; + }); + @override + void dispose() { + hex.dispose(); + super.dispose(); + } + + Widget swatch(String name, int value) => Tooltip( + message: '$name · #${rgbHex(value)}', + child: Semantics( + label: name, + selected: rgb == value, + button: true, + child: SizedBox.square( + dimension: 44, + child: Padding( + padding: const EdgeInsets.all(3), + child: Material( + color: Color(0xff000000 | value), + shape: CircleBorder( + side: BorderSide( + color: rgb == value + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: 2, + ), + ), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => setColor(value), + child: rgb == value + ? Icon( + Icons.check, + size: 18, + color: Color(0xff000000 | value).computeLuminance() > .4 + ? Colors.black + : Colors.white, + ) + : null, + ), + ), + ), + ), + ), + ); + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: 380, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(0xff000000 | rgb), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + rgbName(rgb), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + children: [ + for (final entry in ledColorPalette.entries) + swatch(entry.key, entry.value), + ], + ), + if (_recentLedColors.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('Recent colors'), + Wrap( + children: [ + for (final value in _recentLedColors) + swatch(rgbName(value), value), + ], + ), + ], + const SizedBox(height: 12), + for (final part in ['Hue', 'Saturation', 'Brightness']) ...[ + Text(part), + Slider( + label: part == 'Hue' + ? '${hsv.hue.round()}°' + : '${((part == 'Saturation' ? hsv.saturation : hsv.value) * 100).round()}%', + value: part == 'Hue' + ? hsv.hue + : part == 'Saturation' + ? hsv.saturation + : hsv.value, + max: part == 'Hue' ? 360 : 1, + onChanged: (v) => change( + part == 'Hue' + ? hsv.withHue(v) + : part == 'Saturation' + ? hsv.withSaturation(v) + : hsv.withValue(v), + ), + ), + ], + TextField( + controller: hex, + maxLength: 6, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]')), + ], + decoration: InputDecoration( + labelText: 'RGB hex', + prefixText: '#', + errorText: valid ? null : 'Enter six hexadecimal digits', + ), + onChanged: (text) => setState(() { + valid = text.length == 6; + if (valid) { + hsv = HSVColor.fromColor( + Color(0xff000000 | int.parse(text, radix: 16)), + ); + } + }), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: valid ? () => Navigator.pop(context, rgb) : null, + icon: const Icon(Icons.check), + label: const Text('Apply'), + ), + ], + ); +} diff --git a/mobile/lib/command_failure.dart b/mobile/lib/command_failure.dart index ca66becb..068be3cc 100644 --- a/mobile/lib/command_failure.dart +++ b/mobile/lib/command_failure.dart @@ -2,6 +2,30 @@ // credentials, requested text, remote URLs or uploaded payloads in an error. String commandFailureMessage({Object? error, int? httpStatus}) { const reasons = { + 'input_not_accepted_in_current_phase': + 'Wait for the input phase before pressing a box', + 'minigame_avr_ownership_not_ready': + 'The game is transferring input ownership; wait for Ready', + 'game_already_started_or_unconfigured': + 'The game is already running; pause, reset or stop it first', + 'game_not_running': 'The round is not running', + 'game_not_paused': 'The round is not paused', + 'inactive_key': 'This box is not participating in the current game', + 'key_already_held': 'Release this box before pressing it again', + 'invalid_minigame_settings': + 'One or more game settings are outside the allowed range', + 'minigame_settings_not_saved': 'The board could not save the game settings', + 'presentation_busy': 'Another presentation currently owns the LEDs', + 'corrections_preview_owned': 'Another editor owns the correction preview', + 'corrections_preview_expired': + 'The correction preview expired; reopen it before editing', + 'corrections_conflict': 'Saved corrections changed; reload before editing', + 'invalid_corrections': + 'The correction rules contain invalid addresses or values', + 'invalid_preview_lease': 'The correction preview lease is invalid', + 'invalid_preview_ttl': + 'The correction preview duration is outside its safe limit', + 'avr_outcome_unknown': 'The AVR did not acknowledge before the deadline; inspect its state before issuing another command', 'capability_not_acknowledged': 'This board has not acknowledged this feature', 'unsupported_command': 'The board does not support this command', diff --git a/mobile/lib/command_readback.dart b/mobile/lib/command_readback.dart index e3916223..a3d70ba7 100644 --- a/mobile/lib/command_readback.dart +++ b/mobile/lib/command_readback.dart @@ -7,6 +7,7 @@ import 'cell_assignments.dart'; import 'game_settings_command.dart'; import 'semantic_cells.dart'; import 'led_backend.dart'; +import 'minigame.dart'; /// Compare observed command state, never draft values or model defaults. /// A matching buffer/status is not physical LED/display/audio acceptance. @@ -17,6 +18,10 @@ bool commandReadbackMatches( ) { final json = actual.json; switch (path) { + case minigameConfigure: + case minigameControl: + case minigameInput: + return minigameReadbackMatches(json['minigame'], path, values); case '/api/outputs': final requested = int.tryParse(values['mask'] ?? ''); return requested != null && requested >= 0 && requested <= 255 && diff --git a/mobile/lib/command_wire.dart b/mobile/lib/command_wire.dart index 5dfd2276..a55d0f45 100644 --- a/mobile/lib/command_wire.dart +++ b/mobile/lib/command_wire.dart @@ -7,8 +7,17 @@ import 'command_failure.dart'; import 'cell_assignments.dart'; import 'game_settings_command.dart'; import 'command_readback.dart'; +import 'minigame.dart'; const commandNames = { + '/api/network/tools': 'configure-network-tools', + '/api/network/probe': 'network-probe', + minigameConfigure: 'minigame-configure', + minigameControl: 'minigame-control', + minigameInput: 'minigame-input', + '/api/audio': 'audio', + '/api/buzzer': 'buzzer', + '/api/outputs': 'outputs', '/api/led/presentation': 'state-presentation', '/api/led/preview': 'color-preview', '/api/game/press': 'game-press', @@ -23,8 +32,20 @@ const commandNames = { '/api/brightness': 'brightness', '/api/led/output': 'led-output', '/api/led/diagnostic': 'pixel-diagnostic', + '/api/pixels/corrections/preview': 'pixel-corrections-preview', }; +// Receipt ownership is identity, not admission for a subsequent mutation. A +// valid configure/start receipt can temporarily report ready=false during the +// AVR input fence. Never turn that successful handoff into a disconnection. +bool commandPeerMatches(DeviceStatus? actual, Object? board, Object? boot) => + board is String && + board.isNotEmpty && + boot is String && + boot.isNotEmpty && + actual?.json['board_id'] == board && + actual?.json['boot_id'] == boot; + String commandPacket( DeviceStatus status, String path, @@ -45,6 +66,9 @@ String commandPacket( final gameSettings = path == '/api/game/settings' ? gameSettingsPatch(values) : null; + final miniFields = minigamePaths.contains(path) + ? status.peer.minigame!.command(path, values) + : null; return jsonEncode({ 'type': 'command', 'request_id': id, @@ -53,9 +77,16 @@ String commandPacket( 'expires_at_ms': clock + 8000, 'command': commandNames[path], ...?gameSettings, - if (gameSettings == null) + ...?miniFields, + if (gameSettings == null && miniFields == null) for (final item in values.entries) - item.key: path == '/api/led/presentation' && item.key == 'settings' + item.key: + path == '/api/pixels/corrections/preview' && + item.key == 'configuration' + ? jsonDecode(item.value) + : (path == '/api/led/presentation' || + path == '/api/network/tools') && + item.key == 'settings' ? jsonDecode(item.value) : path == '/api/led/preview' && ['rgb', 'duration_ms'].contains(item.key) @@ -64,7 +95,10 @@ String commandPacket( ? item.value == 'true' : path == '/api/cells/assignments' && item.key == 'assignments' ? CellAssignments.fromRows(jsonDecode(item.value)).wireAssignments - : item.key == 'id' || + : (path == '/api/buzzer' && + ['frequency', 'duration_ms'].contains(item.key)) || + (path == '/api/outputs' && item.key == 'mask') || + item.key == 'id' || (path == '/api/game/press' && item.key == 'key') || (path == '/api/led/diagnostic' && [ @@ -102,7 +136,13 @@ DeviceStatus verifyCommandReply( ); } if (reply['accepted'] != true) { - throw DeviceCommandRejected(error: reply['error']); + throw DeviceCommandRejected(error: reply['reason'] ?? reply['error']); + } + if (const ['/api/audio', '/api/buzzer', '/api/outputs'].contains(path) && + reply['avr_acknowledged'] != true) { + throw const FormatException( + 'AVR command acknowledgment missing; command was not retried', + ); } if ((path == '/api/game/reset' || path == '/api/game/press') && reply['avr_acknowledged'] != true) { @@ -128,6 +168,10 @@ DeviceStatus verifyCommandReply( throw const FormatException('Same-board command readback unavailable'); } final actual = DeviceStatus(json); + if (minigamePaths.contains(path) && + !minigameReadbackMatches(json['minigame'], path, values)) { + throw const FormatException('Minigame readback differs; not retried'); + } if (requireGameReadback && path == '/api/game/settings' && !commandReadbackMatches(actual, path, values)) { diff --git a/mobile/lib/device_settings_page.dart b/mobile/lib/device_settings_page.dart index 46343740..8fdcc141 100644 --- a/mobile/lib/device_settings_page.dart +++ b/mobile/lib/device_settings_page.dart @@ -587,9 +587,16 @@ class _DeviceSettingsPageState extends State { } Future scan() => runWrite('network', () async { - final value = await widget.api.getJson('/api/wifi/scan'); + var value = await widget.api.getJson('/api/wifi/scan'); + for (var attempt = 0; value['running'] == true && attempt < 30; attempt++) { + await Future.delayed(const Duration(milliseconds: 500)); + check('network'); + value = await widget.api.getJson('/api/wifi/scan?results=true'); + } check('network'); - if (value['networks'] is! List) { + if (value['running'] == true || + value['error'] != null || + value['networks'] is! List) { throw const FormatException('Wi-Fi scan unavailable'); } ssids = diff --git a/mobile/lib/devices_page.dart b/mobile/lib/devices_page.dart index ceff29d4..3966206e 100644 --- a/mobile/lib/devices_page.dart +++ b/mobile/lib/devices_page.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import 'device_registry.dart'; +import 'host_wifi.dart'; import 'discovery.dart'; import 'icons.dart'; import 'cell_controls.dart'; import 'application_artwork.dart'; + import 'dart:convert'; import 'api.dart'; import 'workspace_widgets.dart'; import 'ble_transport.dart'; import 'usb_transport.dart'; + final _discoveryFallbackArtwork = base64Decode(applicationArtworkBase64); class DeviceListEntry { @@ -589,6 +592,19 @@ class _DevicesPageState extends State { icon: PakarIcons.circuitry, subtitle: '$count available · $savedCount saved', actions: [ + if (method == ConnectionMethod.network && HostWifi.supported) + IconButton.outlined( + tooltip: 'Join or manage this device’s Wi-Fi', + onPressed: connecting + ? null + : () => showHostWifi( + context, + knownNetworks: HostWifi.knownNetworks( + widget.api?.latestStatus?.json, + ), + ), + icon: const Icon(PakarIcons.wifiHigh), + ), OutlinedButton.icon( onPressed: busy || connecting ? null : scan, icon: const Icon(PakarIcons.arrowClockwise), @@ -744,15 +760,20 @@ class _DevicesPageState extends State { vertical: 8, ), leading: entry.peer.application?.icon != null - ? SizedBox(width: 40, height: 40, child: StableApplicationIcon( - key: ValueKey(entry.identityKey), - bytes: entry.peer.application!.icon!, - fallback: _discoveryFallbackArtwork)) + ? SizedBox( + width: 40, + height: 40, + child: StableApplicationIcon( + key: ValueKey(entry.identityKey), + bytes: entry.peer.application!.icon!, + fallback: _discoveryFallbackArtwork, + ), + ) : Icon( - entry.saved?.location == DeviceLocation.remote - ? PakarIcons.cloud - : PakarIcons.circuitry, - ), + entry.saved?.location == DeviceLocation.remote + ? PakarIcons.cloud + : PakarIcons.circuitry, + ), title: Text( entry.name, maxLines: 1, @@ -853,10 +874,38 @@ class _DevicesPageState extends State { trailing: PopupMenuButton( enabled: !connecting, tooltip: 'Device actions', - onSelected: (value) => value == 'forget' + onSelected: (value) => value == 'wifi' + ? showHostWifi( + context, + scope: entry.identityKey, + knownNetworks: + entry.peer.boardId.isNotEmpty && + entry.peer.boardId == + widget + .api + ?.latestStatus + ?.json['board_id'] + ? HostWifi.knownNetworks( + widget.api?.latestStatus?.json, + ) + : const [], + ) + : value == 'forget' ? forget(entry.saved!) : edit(device: entry.saved, discovered: entry.peer), itemBuilder: (_) => [ + if (method == ConnectionMethod.network && + HostWifi.supported) + const PopupMenuItem( + value: 'wifi', + child: Row( + children: [ + Icon(PakarIcons.wifiHigh, size: 18), + SizedBox(width: 10), + Text('Join Wi-Fi…'), + ], + ), + ), PopupMenuItem( value: 'edit', child: Row( diff --git a/mobile/lib/game_presentation_editor.dart b/mobile/lib/game_presentation_editor.dart index 81eeb7a0..1a98fbb4 100644 --- a/mobile/lib/game_presentation_editor.dart +++ b/mobile/lib/game_presentation_editor.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'game_settings_command.dart'; +import 'color_picker.dart'; /// Reuses PCController EffectEditor local JSON copy, keyed numeric drafts and /// validation-before-save; game fields replace its host sequence definition. @@ -70,6 +71,21 @@ class _GamePresentationEditorState extends State { }), ), ); + Widget color(String name, String label, int value) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(label), + subtitle: Text(rgbName(value)), + leading: Icon(Icons.circle, color: Color(0xff000000 | value)), + trailing: const Icon(Icons.palette_outlined), + onTap: () async { + final next = await showLedColorPicker( + context, + initial: value, + title: label, + ); + if (next != null && mounted) setState(() => field(name, next)); + }, + ); @override Widget build(BuildContext context) { final current = selected == 0 ? draft : group; @@ -116,17 +132,11 @@ class _GamePresentationEditorState extends State { onChanged: (v) => setState(() => field('override_colors', v)), ), if (current['override_colors'] == true) ...[ - number( - 'win_rgb', - 'Winner color', - current['win_rgb'], - hex: true, - ), - number( + color('win_rgb', 'Winner color', current['win_rgb'] as int), + color( 'lose_rgb', 'Other box color', - current['lose_rgb'], - hex: true, + current['lose_rgb'] as int, ), ], for (final name in ['win_motion', 'lose_motion']) diff --git a/mobile/lib/host_wifi.dart b/mobile/lib/host_wifi.dart new file mode 100644 index 00000000..f006b76f --- /dev/null +++ b/mobile/lib/host_wifi.dart @@ -0,0 +1,279 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'icons.dart'; + +/// Host networking, not board configuration or board authentication. +class HostWifi { + static const channel = MethodChannel('ir.digitalogic.pakar/host-wifi'); + static bool get supported => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + static List knownNetworks(Map? status) => { + for (final key in ['ap_ssid', 'station_ssid']) + if (status?[key] is String && + validate(status![key] as String, '', true) == null) + status[key] as String, + }.toList(); + static String? validate(String ssid, String password, bool open) { + if (ssid.isEmpty || + utf8.encode(ssid).length > 32 || + RegExp(r'[\x00-\x1f\x7f]').hasMatch(ssid)) { + return 'Enter a Wi-Fi name of 1–32 bytes, without control characters.'; + } + if (!open && + (password.length < 8 || + password.length > 63 || + password.codeUnits.any((c) => c < 32 || c > 126))) { + return 'Enter the network’s 8–63 character WPA2 password.'; + } + return null; + } + + static Future request( + String method, + String ssid, + String password, + bool open, + ) async { + final error = validate(ssid, password, open); + if (error != null) throw FormatException(error); + return await channel.invokeMethod(method, { + 'ssid': ssid, + 'password': open ? '' : password, + 'open': open, + }) ?? + 'cancelled'; + } +} + +Future showHostWifi( + BuildContext context, { + String scope = 'manual', + List knownNetworks = const [], +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _HostWifiDialog(scope: scope, knownNetworks: knownNetworks), +); + +class _HostWifiDialog extends StatefulWidget { + const _HostWifiDialog({required this.scope, required this.knownNetworks}); + final String scope; + final List knownNetworks; + @override + State<_HostWifiDialog> createState() => _HostWifiDialogState(); +} + +class _HostWifiDialogState extends State<_HostWifiDialog> { + final ssid = TextEditingController(), password = TextEditingController(); + bool open = false, busy = false; + String message = ''; + String get hintKey => 'pakar.host-wifi.${Uri.encodeComponent(widget.scope)}'; + @override + void initState() { + super.initState(); + restore(); + } + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted && ssid.text.isEmpty) { + ssid.text = prefs.getString(hintKey) ?? ''; + } + } + + @override + void dispose() { + ssid.dispose(); + password.dispose(); + super.dispose(); + } + + Future run(String action) async { + setState(() { + busy = true; + message = ''; + }); + try { + if (action == 'settings' || action == 'release') { + await HostWifi.channel.invokeMethod(action); + if (mounted) { + setState( + () => message = action == 'release' + ? 'App-only Wi-Fi released. Android manages the network again.' + : 'Choose the board’s network in Android Wi-Fi settings, then return and Discover.', + ); + } + } else { + final result = await HostWifi.request( + action, + ssid.text, + password.text, + open, + ); + if (!mounted) return; + // Remember only the explicitly entered name. Never store Wi-Fi passwords. + if (result == 'available' || result == 'saved') { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(hintKey, ssid.text); + } + if (!mounted) return; + setState( + () => message = switch (result) { + 'available' => 'Wi-Fi available to this app. Close, then Discover or connect to verify the board. Other app traffic also uses this local network until released.', + 'saved' => 'Network saved in Android. Use Wi-Fi settings to select it, then Discover.', + _ => 'Wi-Fi request cancelled. No board commands were sent.', + }, + ); + } + } on Object catch (error) { + if (mounted) { + setState( + () => message = error is PlatformException + ? error.message ?? 'Wi-Fi operation unavailable' + : error is FormatException + ? error.message + : 'Wi-Fi operation failed. Use system Wi-Fi settings.', + ); + } + } finally { + if (mounted) setState(() => busy = false); + } + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !busy, + child: AlertDialog( + title: const Row( + children: [ + Icon(PakarIcons.wifiHigh), + SizedBox(width: 12), + Expanded(child: Text('This device’s Wi-Fi')), + ], + ), + content: SizedBox( + width: 430, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Join the board’s access point or the same router. Android asks for confirmation. App-only joins also route this app’s cloud traffic over that network; board settings are unchanged.', + ), + const SizedBox(height: 16), + if (widget.knownNetworks.isNotEmpty) + Wrap( + spacing: 8, + children: widget.knownNetworks + .toSet() + .map( + (name) => ActionChip( + avatar: const Icon(PakarIcons.wifiHigh, size: 16), + label: Text(name), + onPressed: busy + ? null + : () { + ssid.text = name; + password.clear(); + }, + ), + ) + .toList(), + ), + TextField( + controller: ssid, + enabled: !busy, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Wi-Fi name (SSID)', + prefixIcon: Icon(PakarIcons.wifiHigh), + ), + ), + const SizedBox(height: 12), + if (!open) + TextField( + controller: password, + enabled: !busy, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Wi-Fi password', + prefixIcon: Icon(PakarIcons.lock), + ), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Open network'), + value: open, + onChanged: busy ? null : (v) => setState(() => open = v!), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: busy ? null : () => run('join'), + icon: const Icon(PakarIcons.link), + label: const Text('Join for this app'), + ), + OutlinedButton.icon( + onPressed: busy ? null : () => run('save'), + icon: const Icon(PakarIcons.floppyDisk), + label: const Text('Save in Android'), + ), + TextButton.icon( + onPressed: busy ? null : () => run('settings'), + icon: const Icon(PakarIcons.gear), + label: const Text('Wi-Fi settings'), + ), + TextButton( + onPressed: busy ? null : () => run('release'), + child: const Text('Release app-only Wi-Fi'), + ), + ], + ), + if (busy) + const Padding( + padding: EdgeInsets.only(top: 12), + child: LinearProgressIndicator(), + ), + if (message.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Semantics(liveRegion: true, child: Text(message)), + ), + ], + ), + ), + ), + actions: [ + if (busy) + TextButton( + onPressed: () async { + try { + await HostWifi.channel.invokeMethod('cancel'); + } on PlatformException catch (error) { + if (mounted) { + setState( + () => message = error.message ?? 'Cancellation failed', + ); + } + } + }, + child: const Text('Cancel request'), + ), + TextButton( + onPressed: busy ? null : () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); +} diff --git a/mobile/lib/key_box_designer.dart b/mobile/lib/key_box_designer.dart new file mode 100644 index 00000000..e5fc4da3 --- /dev/null +++ b/mobile/lib/key_box_designer.dart @@ -0,0 +1,938 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'key_pixel_preview.dart'; +import 'models.dart'; +import 'table_workspace.dart'; +import 'workspace_widgets.dart'; + +/// PCController ActivityStrip's adaptive accent/Material/InkWell composition, +/// with Pealayer's reserved action gutter, grab-offset-preserving drag and +/// color-only hover. Layout editing never owns the hardware command callback. +class KeyBoxDesigner extends StatefulWidget { + const KeyBoxDesigner({ + super.key, + required this.status, + required this.workspace, + required this.enabled, + required this.onTap, + required this.openSettings, + this.onHoldStart, + this.onHoldEnd, + this.onReset, + this.onBlockedTap, + this.blockedReason, + this.inputKeyPrefix = 'game-key-drag', + }); + + final DeviceStatus status; + final TableWorkspace workspace; + final bool enabled; + final ValueChanged onTap; + final VoidCallback openSettings; + final ValueChanged? onHoldStart; + final VoidCallback? onHoldEnd; + final VoidCallback? onReset; + final ValueChanged? onBlockedTap; + final String? blockedReason; + final String inputKeyPrefix; + + @override + State createState() => _KeyBoxDesignerState(); +} + +class _KeyBoxDesignerState extends State { + bool arranging = false, multiple = false; + int? dragging; + Offset? pointer; + final dragOrigins = {}; + Offset dragDelta = Offset.zero; + + int get mask { + final config = widget.status.json['key_game']; + return config is Map && config['enabled_mask'] is int + ? config['enabled_mask'] as int + : 0; + } + + bool active(int key) => mask & (1 << (key - 1)) != 0; + int number(String name) { + final raw = widget.status.json['minigame']; + return raw is Map && raw[name] is int ? raw[name] as int : 0; + } + + bool pressed(int key) => + number('held_mask') & (1 << (key - 1)) != 0 || + (key <= 8 + ? widget.status.activeInputs & (1 << (key - 1)) != 0 + : widget.status.json['input_9'] == true); + + int get winner => + number('winner') > 0 ? number('winner') : widget.status.winner; + + @override + void didUpdateWidget(covariant KeyBoxDesigner oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.workspace != widget.workspace || + oldWidget.status.json['board_id'] != widget.status.json['board_id']) { + arranging = false; + dragging = null; + dragOrigins.clear(); + } + } + + void select(int key) { + if (!multiple) { + widget.workspace.selectKeys( + widget.workspace.selectedKeys.contains(key) ? {} : {key}, + ); + return; + } + final next = multiple ? {...widget.workspace.selectedKeys} : {}; + if (!next.add(key)) next.remove(key); + widget.workspace.selectKeys(next); + } + + Future menu(int key) async { + final enabled = active(key); + final owner = ( + widget.status.json['board_id'], + widget.status.json['boot_id'], + ); + bool sameOwner() { + if (!mounted) return false; + final same = + owner == + (widget.status.json['board_id'], widget.status.json['boot_id']); + if (!same) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'The connected board changed. Open the box actions again.', + ), + ), + ); + } + return same; + } + + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.sports_esports_outlined), + title: Text('Box $key'), + subtitle: Text( + widget.blockedReason ?? + (enabled ? 'Button · Indicator' : 'Disabled'), + ), + ), + ListTile( + leading: const Icon(Icons.touch_app_outlined), + title: const Text('Trigger box'), + enabled: widget.enabled && enabled && !arranging, + onTap: () { + Navigator.pop(context); + if (sameOwner() && + widget.enabled && + active(key) && + !arranging) { + widget.onTap(key); + } + }, + ), + if (widget.onReset != null) + ListTile( + leading: const Icon(Icons.restart_alt), + title: const Text('Reset to white'), + enabled: widget.enabled && !arranging, + onTap: () { + Navigator.pop(context); + if (sameOwner()) widget.onReset!(); + }, + ), + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text( + widget.workspace.selectedKeys.contains(key) + ? 'Deselect box' + : 'Select box', + ), + onTap: () { + Navigator.pop(context); + if (!sameOwner()) return; + setState(() { + arranging = true; + multiple = true; + }); + select(key); + }, + ), + ListTile( + leading: const Icon(Icons.tune), + title: const Text('Configure boxes'), + onTap: () { + Navigator.pop(context); + if (sameOwner()) widget.openSettings(); + }, + ), + ], + ), + ), + ), + ); + } + + Color? fallback(int key) { + final mini = widget.status.json['minigame']; + if (mini is Map && mini['mode'] != 'press-to-win') return null; + final raw = widget.status.json['key_game']; + final field = winner == 0 + ? 'idle_rgb' + : winner == key + ? 'win_rgb' + : 'lose_rgb'; + final rgb = raw is Map ? raw[field] : null; + return rgb is int ? Color(0xff000000 | rgb) : null; + } + + void move(int key, Offset delta, Size travel) { + if (dragging == null) { + dragging = key; + if (!widget.workspace.selectedKeys.contains(key)) { + widget.workspace.selectKeys({key}); + } + dragOrigins + ..clear() + ..addEntries( + widget.workspace.selectedKeys.map( + (id) => MapEntry(id, widget.workspace.keyPosition(id)), + ), + ); + dragDelta = Offset.zero; + } + dragDelta += delta; + // Clamp the selection as a group; relative spacing survives edge dragging. + var dx = travel.width == 0 ? 0.0 : dragDelta.dx / travel.width; + var dy = travel.height == 0 ? 0.0 : dragDelta.dy / travel.height; + for (final start in dragOrigins.values) { + dx = dx.clamp(-start.dx, 1 - start.dx); + dy = dy.clamp(-start.dy, 1 - start.dy); + } + for (final entry in dragOrigins.entries) { + widget.workspace.moveKey(entry.key, entry.value + Offset(dx, dy)); + } + setState(() {}); + } + + void finishDrag(Size travel) { + if (dragging == null) return; + // Snap once on release, keeping pointer movement continuous while dragging. + final origin = widget.workspace.keyPosition(dragging!); + final snap = Offset( + travel.width == 0 + ? 0 + : (origin.dx * travel.width / 12).round() * 12 / travel.width, + travel.height == 0 + ? 0 + : (origin.dy * travel.height / 12).round() * 12 / travel.height, + ); + var delta = snap - origin; + for (final key in dragOrigins.keys) { + final at = widget.workspace.keyPosition(key); + delta = Offset( + delta.dx.clamp(-at.dx, 1 - at.dx), + delta.dy.clamp(-at.dy, 1 - at.dy), + ); + } + for (final key in dragOrigins.keys) { + widget.workspace.moveKey(key, widget.workspace.keyPosition(key) + delta); + } + setState(() { + dragging = null; + dragOrigins.clear(); + }); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: widget.workspace, + builder: (context, _) { + final colors = Theme.of(context).colorScheme; + final selected = widget.workspace.selectedKeys; + return Container( + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: .35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 14, 12), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 20, + runSpacing: 12, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: .12), + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + arranging + ? Icons.dashboard_customize_outlined + : Icons.sports_esports_outlined, + color: colors.primary, + size: 21, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + arranging ? 'Box designer' : 'Live boxes', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + '${List.generate(9, (i) => i + 1).where(active).length} enabled · ${arranging ? '${selected.length} selected' : 'Button + indicator'}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FilterChip( + key: const ValueKey('key-designer-arrange'), + avatar: Icon( + arranging ? Icons.check : Icons.open_with, + size: 18, + ), + label: Text( + arranging ? 'Done arranging' : 'Arrange boxes', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + selected: arranging, + onSelected: (value) => setState(() { + widget.onHoldEnd?.call(); + arranging = value; + dragging = null; + }), + ), + ), + const SizedBox(width: 6), + PopupMenuButton( + tooltip: 'Box workspace options', + icon: const Icon(Icons.more_horiz), + onSelected: (action) { + if (action == 'settings') widget.openSettings(); + if (action == 'reset') { + widget.workspace.resetKeyPositions(); + } + if (action == 'all') { + widget.workspace.selectKeys({ + for (var k = 1; k <= 9; k++) k, + }); + } + if (action == 'none') widget.workspace.selectKeys({}); + }, + itemBuilder: (_) => [ + workspaceMenuItem( + 'settings', + 'Configure boxes', + Icons.tune, + ), + if (arranging) ...[ + workspaceMenuItem( + 'all', + 'Select all boxes', + Icons.select_all, + ), + workspaceMenuItem( + 'none', + 'Clear selection', + Icons.deselect, + ), + workspaceMenuItem( + 'reset', + 'Reset positions', + Icons.grid_view_outlined, + ), + ], + ], + ), + ], + ), + ], + ), + ), + if (arranging) + Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 12), + child: Wrap( + spacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilterChip( + label: const Text('Multiple selection'), + selected: multiple, + avatar: const Icon( + Icons.library_add_check_outlined, + size: 17, + ), + onSelected: (value) => setState(() => multiple = value), + ), + Text( + 'Drag to arrange · Snap to grid', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + LayoutBuilder( + builder: (context, bounds) { + final width = math.min(bounds.maxWidth, 900.0); + final compact = width < 480; + final cardWidth = math.min(220.0, (width - 48) / 3); + final cardHeight = compact ? 142.0 : 176.0; + final canvasHeight = cardHeight * 3 + (compact ? 40 : 56); + final travel = Size( + width - cardWidth - 32, + canvasHeight - cardHeight - 32, + ); + return Align( + child: SizedBox( + width: width, + height: canvasHeight, + child: MouseRegion( + onHover: (event) => + setState(() => pointer = event.localPosition), + onExit: (_) => setState(() => pointer = null), + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(24), + ), + child: Stack( + key: const ValueKey('key-layout-canvas'), + children: [ + Positioned.fill( + child: CustomPaint( + painter: _BoxGrid( + colors.primary, + colors.outlineVariant, + arranging, + pointer, + dragging != null, + ), + ), + ), + for (var key = 1; key <= 9; key++) + Positioned( + key: ValueKey('game-key-position-$key'), + left: + 16 + + widget.workspace.keyPosition(key).dx * + travel.width, + top: + 16 + + widget.workspace.keyPosition(key).dy * + travel.height, + width: cardWidth, + height: cardHeight, + child: Semantics( + button: true, + selected: selected.contains(key), + label: 'Key $key', + enabled: + arranging || + (widget.enabled && active(key)), + child: GestureDetector( + key: ValueKey( + '${widget.inputKeyPrefix}-$key', + ), + behavior: HitTestBehavior.opaque, + onTap: arranging + ? () => select(key) + : widget.enabled && active(key) + ? () => widget.onTap(key) + : widget.onBlockedTap == null + ? null + : () => widget.onBlockedTap!(key), + onLongPress: + !arranging && widget.onHoldStart == null + ? () => menu(key) + : null, + onLongPressStart: + !arranging && + widget.enabled && + active(key) && + widget.onHoldStart != null + ? (_) => widget.onHoldStart!(key) + : null, + onLongPressEnd: (_) => + widget.onHoldEnd?.call(), + onLongPressCancel: widget.onHoldEnd, + onPanUpdate: arranging + ? (details) => + move(key, details.delta, travel) + : null, + onPanEnd: arranging + ? (_) => finishDrag(travel) + : null, + onPanCancel: arranging + ? () => finishDrag(travel) + : null, + child: _BoxCard( + key: ValueKey('game-key-$key'), + number: key, + active: active(key), + selected: + arranging && selected.contains(key), + pressed: pressed(key), + winner: winner == key, + latched: + number('latched_mask') & + (1 << (key - 1)) != + 0, + lost: winner != 0 && winner != key, + dragging: + dragging != null && + selected.contains(key), + arranging: arranging, + illumination: keyPixelGroup( + widget.status, + key, + 0, + ), + indicator: keyPixelGroup( + widget.status, + key, + 1, + ), + fallback: fallback(key), + compact: compact, + onMenu: () => menu(key), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ], + ), + ); + }, + ); +} + +class _BoxCard extends StatefulWidget { + const _BoxCard({ + super.key, + required this.number, + required this.active, + required this.selected, + required this.pressed, + required this.winner, + required this.latched, + required this.lost, + required this.dragging, + required this.arranging, + required this.compact, + required this.onMenu, + this.illumination, + this.indicator, + this.fallback, + }); + final int number; + final bool active, + selected, + pressed, + winner, + latched, + lost, + dragging, + arranging, + compact; + final VoidCallback onMenu; + final List? illumination, indicator; + final Color? fallback; + @override + State<_BoxCard> createState() => _BoxCardState(); +} + +class _BoxCardState extends State<_BoxCard> { + bool hover = false; + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme; + final accent = widget.selected || widget.pressed + ? c.primary + : widget.winner || widget.latched + ? const Color(0xff38d996) + : widget.lost + ? c.error + : c.onSurfaceVariant; + final bright = + widget.active && + (widget.selected || widget.pressed || widget.winner || widget.latched); + return MouseRegion( + cursor: widget.arranging + ? (widget.dragging + ? SystemMouseCursors.grabbing + : SystemMouseCursors.grab) + : widget.active + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + onEnter: (_) => setState(() => hover = true), + onExit: (_) => setState(() => hover = false), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + padding: EdgeInsets.all(widget.compact ? 8 : 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.alphaBlend( + accent.withValues( + alpha: bright + ? .16 + : hover + ? .08 + : .025, + ), + c.surfaceContainerHigh, + ), + c.surfaceContainerLow, + ], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + width: 1.5, + color: bright + ? accent.withValues(alpha: .8) + : hover + ? c.primary.withValues(alpha: .5) + : c.outlineVariant.withValues(alpha: .35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: widget.dragging ? .24 : .08, + ), + blurRadius: widget.dragging ? 22 : 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Opacity( + opacity: widget.active ? 1 : .36, + child: Column( + children: [ + Row( + children: [ + Text( + widget.compact + ? '${widget.number}'.padLeft(2, '0') + : 'BOX ${'${widget.number}'.padLeft(2, '0')}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + letterSpacing: .8, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + SizedBox( + width: 28, + height: 28, + child: IconButton( + padding: EdgeInsets.zero, + tooltip: 'Box ${widget.number} actions', + onPressed: widget.onMenu, + icon: Icon( + widget.arranging + ? Icons.drag_indicator + : Icons.more_horiz, + size: 19, + ), + ), + ), + ], + ), + const Spacer(), + Row( + children: [ + Expanded( + child: _LedDial( + label: 'Button', + colors: widget.illumination, + fallback: widget.fallback, + button: true, + ), + ), + const SizedBox(width: 6), + Expanded( + child: _LedDial( + label: 'Indicator', + colors: widget.indicator, + fallback: widget.fallback, + button: false, + ), + ), + ], + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + !widget.active + ? Icons.do_not_disturb_on_outlined + : widget.selected + ? Icons.check_circle + : widget.winner + ? Icons.emoji_events_outlined + : widget.pressed + ? Icons.touch_app + : widget.latched + ? Icons.toggle_on_outlined + : widget.lost + ? Icons.remove_circle_outline + : Icons.circle, + size: + widget.active && + !widget.selected && + !widget.winner && + !widget.latched && + !widget.lost && + !widget.pressed + ? 6 + : 13, + color: accent, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + !widget.active + ? 'Disabled' + : widget.arranging + ? widget.selected + ? 'Selected' + : 'Move' + : widget.winner + ? 'Winner' + : widget.pressed + ? 'Held' + : widget.latched + ? 'On' + : widget.lost + ? 'Lost' + : 'Ready', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _LedDial extends StatelessWidget { + const _LedDial({ + required this.label, + required this.colors, + required this.fallback, + required this.button, + }); + final String label; + final List? colors; + final Color? fallback; + final bool button; + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return LayoutBuilder( + builder: (context, bounds) { + final diameter = math.min(64.0, bounds.maxWidth); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: diameter, + child: CustomPaint( + painter: _LedRing(colors, fallback, scheme, button), + ), + ), + const SizedBox(height: 5), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(fontSize: 10, color: scheme.onSurfaceVariant), + ), + ], + ); + }, + ); + } +} + +class _LedRing extends CustomPainter { + _LedRing(this.pixels, this.fallback, this.scheme, this.button); + final List? pixels; + final Color? fallback; + final ColorScheme scheme; + final bool button; + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero), radius = size.shortestSide * .43; + final rect = Rect.fromCircle(center: center, radius: radius); + final count = pixels?.length ?? 12; + canvas.drawCircle( + center, + radius - 7, + Paint()..color = scheme.surfaceContainerLowest, + ); + for (var index = 0; index < count; index++) { + final rgb = pixels?[index]; + final color = rgb == null + ? fallback ?? scheme.outlineVariant + : Color(0xff000000 | rgb); + final on = rgb == null ? fallback != null : rgb != 0; + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round + ..color = on ? color : scheme.outlineVariant.withValues(alpha: .3); + final start = -math.pi / 2 + index * math.pi * 2 / count; + canvas.drawArc( + rect, + start + .055, + math.max(.02, math.pi * 2 / count - .11), + false, + paint, + ); + } + if (button) { + final shade = fallback ?? scheme.primary; + canvas.drawCircle( + center, + radius - 9, + Paint() + ..shader = RadialGradient( + colors: [ + shade.withValues(alpha: .25), + shade.withValues(alpha: .06), + ], + ).createShader(rect), + ); + canvas.drawCircle( + center, + radius - 9, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = scheme.outlineVariant.withValues(alpha: .5), + ); + } else { + canvas.drawCircle( + center, + 3, + Paint()..color = scheme.onSurfaceVariant.withValues(alpha: .6), + ); + } + } + + @override + bool shouldRepaint(covariant _LedRing old) => + old.pixels != pixels || + old.fallback != fallback || + old.scheme != scheme || + old.button != button; +} + +class _BoxGrid extends CustomPainter { + _BoxGrid( + this.accent, + this.neutral, + this.editing, + this.pointer, + this.dragging, + ); + final Color accent, neutral; + final bool editing, dragging; + final Offset? pointer; + @override + void paint(Canvas canvas, Size size) { + for (double y = 16; y < size.height; y += 18) { + for (double x = 16; x < size.width; x += 18) { + final at = Offset(x, y); + final near = pointer == null + ? 0.0 + : (1 - (at - pointer!).distance / 140).clamp(0.0, 1.0); + canvas.drawCircle( + at, + .8 + near * .6, + Paint() + ..color = Color.lerp( + neutral.withValues(alpha: editing ? .24 : .10), + accent.withValues(alpha: dragging ? .65 : .4), + near, + )!, + ); + } + } + } + + @override + bool shouldRepaint(covariant _BoxGrid old) => + old.accent != accent || + old.neutral != neutral || + old.editing != editing || + old.pointer != pointer || + old.dragging != dragging; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 088261d5..e024f902 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -17,11 +17,13 @@ import 'board_sound_preferences.dart'; import 'winner_notice.dart'; import 'models.dart'; import 'command_readback.dart'; +import 'command_wire.dart' show commandPeerMatches; import 'table_layout.dart'; import 'table_workspace.dart'; import 'table_viewport.dart'; import 'connection_preferences.dart'; import 'header_controls.dart'; +import 'signal_indicator.dart'; import 'automatic_board_connection.dart'; import 'discovery.dart'; import 'device_registry.dart'; @@ -34,7 +36,7 @@ import 'workspace_activity_strip.dart'; import 'application_artwork.dart'; import 'device_settings_page.dart'; import 'devices_page.dart'; -import 'bridge_page.dart'; +import 'advanced_page.dart'; import 'firmware_page.dart'; import 'firmware_progress.dart'; import 'firmware_automation.dart'; @@ -338,6 +340,7 @@ class _ControlShellState extends State [ 'overview', 'table', + 'game', 'output', 'front-panel', 'hardware', @@ -855,7 +858,7 @@ class _ControlShellState extends State (mirror.segmentsEnabled || mirror.buzzerEnabled), pixels: (page == 'table' && activeWorkspace?.showIndicators == true) || - (page == 'output' && presented?.peer.role == 'first-wins'), + (page == 'game' && presented?.peer.role == 'first-wins'), ); } @@ -1054,6 +1057,8 @@ class _ControlShellState extends State bool propagateFailure = false, }) async { final attempt = ++commandAttempt; + final intentBoard = status?.json['board_id']; + final intentBoot = status?.json['boot_id']; if (commandPending) { setState( () => commandFailure = @@ -1087,8 +1092,8 @@ class _ControlShellState extends State authorize: () => mounted && owner == api.connectionRevision && - peer == status?.hostname && - status?.peer.allows(path, values) == true, + api.connectionWanted && + commandPeerMatches(status, intentBoard, intentBoot), ); late final DeviceStatus actual; if (fast != null) { @@ -1126,7 +1131,7 @@ class _ControlShellState extends State !api.connectionWanted || owner != api.connectionRevision || peer != actual.hostname || - !actual.peer.allows(path, values)) { + !commandPeerMatches(actual, intentBoard, intentBoot)) { throw const StaleConnection(); } _observe(actual); @@ -1174,7 +1179,11 @@ class _ControlShellState extends State : SupportIncident.commandRejected, ); if (attempt == commandAttempt) { - setState(() => commandFailure = error.toString()); + setState( + () => commandFailure = error is FormatException + ? error.message.toString() + : error.toString(), + ); } if (error is StaleConnection) { _revoke(error.toString()); @@ -1397,6 +1406,8 @@ class _ControlShellState extends State final intent = await showPixelCorrectionMenu( context, pixel, + api: api, + authorize: current, ); if (intent == null || !current()) return; update(() => correctionIntent = intent); @@ -1457,7 +1468,8 @@ class _ControlShellState extends State ('overview', 'Overview', PakarIcons.broadcast), if (panelInventory.tableSelection) ('table', 'Table', PakarIcons.gridFour), - if (panelInventory.has('table')) ('output', 'Output', PakarIcons.gauge), + if (panelInventory.game) ('game', 'Game', PakarIcons.play), + if (panelInventory.output) ('output', 'Output', PakarIcons.gauge), if (panelInventory.frontPanel) ('front-panel', 'Front panel', PakarIcons.gauge), if (panelInventory.known && @@ -1543,12 +1555,16 @@ class _ControlShellState extends State api.connectionWanted && owner == api.connectionRevision, ), - 'output' => OutputPanel( - key: ValueKey('output:${panelInventory.ownerKey}'), + 'output' || 'game' => OutputPanel( + key: ValueKey('$page:${panelInventory.ownerKey}'), + section: page == 'game' + ? OutputPanelSection.game + : OutputPanelSection.output, status: presented, connected: status != null && foreground, workspace: workspaces[workspaceBoard], command: scoped, + statusUpdates: api.statusUpdates, verifiedCommand: (path, values) => _command( owner, hostname, @@ -1786,14 +1802,19 @@ class _ControlShellState extends State child: Scaffold( appBar: AppBar( title: Text( - target == 'bridge' - ? 'Advanced bridge' + target == 'advanced' + ? 'Advanced' : 'Firmware', ), automaticallyImplyLeading: !locked, ), - body: target == 'bridge' - ? BridgePage(api: api, observed: () => status) + body: target == 'advanced' + ? AdvancedPage(api: api, observed: () => status, + openSettings: (section) { + workspaces[workspaceBoard]?.settings.page = section; + Navigator.of(context).pop(); + _navigate('device-settings'); + }) : FirmwarePage( api: api, authorized: (feature) => @@ -1852,9 +1873,9 @@ class _ControlShellState extends State pixelCorrectionIcon, ), workspaceMenuItem( - 'bridge', - 'Advanced MQTT / bridge…', - PakarIcons.link, + 'advanced', + 'Advanced…', + Icons.tune, ), const PopupMenuDivider(), workspaceMenuItem( @@ -1969,7 +1990,7 @@ class BoardOverview extends StatelessWidget { icon: PakarIcons.play, title: 'Game controls', subtitle: 'Boxes, winner and reset', - onTap: navigate == null ? null : () => navigate!('output'), + onTap: navigate == null ? null : () => navigate!('game'), ), if (peer.has('buzzer') || peer.has('display')) WorkspaceActionCard( @@ -2237,7 +2258,7 @@ class _TablePageState extends State { setState(() { applying = false; batchMessage = applied - ? '${ids.length} cell states read back after one board-side update; physical output not verified.' + ? '${ids.length} cells updated' : failure ?? 'The command did not return a verified result. Nothing was retried.'; }); } @@ -2416,6 +2437,7 @@ class _TablePageState extends State { status: status!, command: widget.verifiedCommand!, readStatus: widget.readStatus!, + statusUpdates: widget.statusUpdates, ids: selection.isEmpty ? '1-80' : selection.toList().join(','), @@ -4242,9 +4264,6 @@ class StatusBar extends StatelessWidget { if (status!.json['station_connected'] == true && status!.stationIp.isNotEmpty) status!.stationIp, - if (status!.json['station_connected'] == true && - status!.rssi != null) - '${status!.rssi} dBm', ].join(' · '); // Keep the donor workspace chrome's 48px touch target, not 48px plus // another 16px of footer padding. Safe insets belong to the workspace. @@ -4324,6 +4343,9 @@ class StatusBar extends StatelessWidget { ), ), ], + if (status?.json['station_connected'] == true && + status?.rssi != null) + SignalIndicator(rssi: status!.rssi!, onConnection: onConnection), if (commandFailed) SizedBox.square( dimension: 48, diff --git a/mobile/lib/minigame.dart b/mobile/lib/minigame.dart new file mode 100644 index 00000000..6bff68b3 --- /dev/null +++ b/mobile/lib/minigame.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; + +const minigameConfigure = '/api/minigame/configure'; +const minigameControl = '/api/minigame/control'; +const minigameInput = '/api/minigame/input'; +const minigamePaths = {minigameConfigure, minigameControl, minigameInput}; + +// These are local command keys; all transports send the peer-bound envelope to +// /api/command. They are never independent HTTP write routes. +const minigameLimits = { + 'limit_ms': (0, 86400000), + 'warning_ms': (0, 86400000), + 'urgent_ms': (0, 86400000), + 'penalty_ms': (0, 86400000), + 'window_ms': (100, 60000), + 'show_ms': (100, 10000), + 'gap_ms': (50, 5000), + 'feedback_ms': (50, 5000), + 'intro_ms': (100, 10000), + 'preview_ms': (0, 10000), + 'mismatch_ms': (600, 10000), + 'rounds': (1, 32), + 'sequence_length': (1, 32), + 'palette_size': (2, 9), + 'fixed_target': (0, 9), +}; +const minigameDescriptions = { + 'press-to-win': 'First press wins; reset for the next round', + 'illuminated': 'Press the glowing target before its window closes', + 'color-match': 'Cycle button colors to match the indicator colors', + 'simon': 'Watch, remember and repeat the light sequence', + 'memory-pairs': 'Reveal colors and find every matching pair', + 'clear-all': 'Press every illuminated button to clear the board', + 'color-order': 'Press the boxes in palette color order', + 'switch-pattern': 'Toggle buttons to copy the indicator pattern', + 'reaction-chase': 'Catch the moving light inside its response window', + 'toggle': 'Control latched or momentary outputs without a timer', +}; +const minigameOutputSources = [ + 'disabled', + 'active', + 'success', + 'failure', + 'held', + 'latched', + 'winner', + 'lost', +]; + +class MiniGameSnapshot { + MiniGameSnapshot._(this.json, this.modes, this.settings); + final Map json; + final Map modes; + final Map settings; + String get mode => json['mode'] as String; + String get phase => json['phase'] as String; + int number(String key) => json[key] as int; + bool get ready => json['ready'] == true; + bool get running => const ['show', 'input', 'feedback'].contains(phase); + bool get acceptsInput => + ready && + (phase == 'input' || + (phase == 'ready' && + const ['press-to-win', 'toggle'].contains(mode))); + bool get usesHold => + mode == 'color-match' || + (mode == 'toggle' && settings['toggle_kind'] == 'press'); + String description(String id) { + for (final entry in json['available_modes'] as List) { + if (entry['id'] == id && entry['description'] is String) { + return entry['description'] as String; + } + } + return minigameDescriptions[id] ?? 'Board-provided game'; + } + + static MiniGameSnapshot? read(Object? raw) { + if (raw is! Map || + raw['ready'] is! bool || + raw['settings'] is! Map || + raw['available_modes'] is! List || + raw['timed'] is! bool) { + return null; + } + final modes = {}; + for (final mode in raw['available_modes'] as List) { + if (mode is! Map || + mode['id'] is! String || + mode['label'] is! String || + (mode['id'] as String).isEmpty || + (mode['label'] as String).isEmpty || + modes.containsKey(mode['id'])) { + return null; + } + modes[mode['id'] as String] = mode['label'] as String; + } + if (!modes.containsKey(raw['mode']) || + !const [ + 'ready', + 'show', + 'input', + 'feedback', + 'success', + 'timeout', + 'failed', + 'paused', + 'stopped', + ].contains(raw['phase'])) { + return null; + } + for (final key in [ + 'revision', + 'elapsed_ms', + 'remaining_ms', + 'participating_mask', + 'held_mask', + 'latched_mask', + 'winner', + 'target', + 'round', + 'progress', + 'mistakes', + 'warning', + 'last_reaction_ms', + 'best_reaction_ms', + ]) { + if (raw[key] is! int || raw[key] < 0 || raw[key] > 0xffffffff) { + return null; + } + } + for (final key in ['participating_mask', 'held_mask', 'latched_mask']) { + if (raw[key] > 511) return null; + } + if (raw['winner'] > 9 || raw['target'] > 9) return null; + return MiniGameSnapshot._( + Map.from(raw), + modes, + Map.from(raw['settings'] as Map), + ); + } + + Map command(String path, Map fields) { + if (!ready) throw const FormatException('Minigame is not ready'); + return _validatedFields(path, fields); + } + + Map _validatedFields( + String path, + Map fields, + ) { + if (path == minigameConfigure) { + if (fields.length != 1 || !fields.containsKey('settings')) { + throw const FormatException('Expected minigame settings'); + } + final raw = jsonDecode(fields['settings']!); + if (raw is! Map || raw.isEmpty) { + throw const FormatException('Empty minigame settings'); + } + final patch = {}; + for (final entry in raw.entries) { + final range = minigameLimits[entry.key], value = entry.value; + bool valid; + if (range != null) { + valid = value is int && value >= range.$1 && value <= range.$2; + } else { + valid = switch (entry.key) { + 'mode' => modes.containsKey(value), + 'hard' || 'intro_enabled' || 'color_arpeggio' => value is bool, + 'toggle_kind' => const ['toggle', 'latch', 'press'].contains(value), + 'outputs' => validOutputs(value), + _ => false, + }; + } + if (!valid) throw FormatException('Invalid ${entry.key}'); + patch[entry.key as String] = value as Object; + } + final merged = {...settings, ...patch}; + if (merged['urgent_ms'] is int && + merged['warning_ms'] is int && + merged['urgent_ms'] > merged['warning_ms']) { + throw const FormatException( + 'Urgent threshold exceeds warning threshold', + ); + } + return {'settings': patch}; + } + if (path == minigameControl && + fields.length == 1 && + const [ + 'start', + 'pause', + 'resume', + 'reset', + 'stop', + ].contains(fields['action'])) { + return {'action': fields['action']!}; + } + if (path == minigameInput && + fields.length == 2 && + RegExp(r'^[1-9]$').hasMatch(fields['key'] ?? '') && + const ['tap', 'down', 'up'].contains(fields['action'])) { + return {'key': int.parse(fields['key']!), 'action': fields['action']!}; + } + throw const FormatException('Invalid minigame command'); + } + + bool allows(String path, Map fields) { + try { + command(path, fields); + return true; + } on FormatException { + return false; + } + } +} + +bool validOutputs(Object? raw) => + raw is List && + raw.length == 8 && + raw.every( + (rule) => + rule is Map && + rule.length == 2 && + minigameOutputSources.contains(rule['source']) && + rule['key'] is int && + (minigameOutputSources.indexOf(rule['source'] as String) < 4 + ? rule['key'] == 0 + : rule['key'] >= 1 && rule['key'] <= 9), + ); + +bool minigameReadbackMatches( + Object? raw, + String path, + Map fields, +) { + final game = MiniGameSnapshot.read(raw); + if (game == null) return false; + if (path == minigameConfigure) { + try { + // A successful save starts an AVR input fence and can report ready=false. + // Validate the saved values without granting admission for another write. + final requested = game._validatedFields(path, fields)['settings'] as Map; + return requested.entries.every( + (entry) => minigameValueMatches(game.settings[entry.key], entry.value), + ); + } on FormatException { + return false; + } + } + // Timed input can immediately advance into feedback/success. The correlated + // receipt proves acceptance; a later phase must not be mistaken for failure. + return true; +} + +bool minigameValueMatches(Object? actual, Object? requested) { + if (requested is Map) { + return actual is Map && + requested.length == actual.length && + requested.entries.every( + (entry) => minigameValueMatches(actual[entry.key], entry.value), + ); + } + if (requested is List) { + return actual is List && + requested.length == actual.length && + List.generate( + requested.length, + (i) => i, + ).every((i) => minigameValueMatches(actual[i], requested[i])); + } + return actual == requested; +} diff --git a/mobile/lib/minigame_panel.dart b/mobile/lib/minigame_panel.dart new file mode 100644 index 00000000..df4c2893 --- /dev/null +++ b/mobile/lib/minigame_panel.dart @@ -0,0 +1,828 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'minigame.dart'; +import 'models.dart'; +import 'workspace_widgets.dart'; +import 'key_box_designer.dart'; +import 'table_workspace.dart'; + +typedef MiniGameCommand = Future Function(String, Map); + +class MiniGamePanel extends StatefulWidget { + const MiniGamePanel({ + super.key, + required this.status, + required this.connected, + required this.command, + required this.openBoxSettings, + required this.workspace, + }); + final DeviceStatus status; + final bool connected; + final MiniGameCommand command; + final VoidCallback openBoxSettings; + final TableWorkspace workspace; + @override + State createState() => _MiniGamePanelState(); +} + +class _MiniGamePanelState extends State + with WidgetsBindingObserver { + bool pending = false; + String? error; + Completer? release; + MiniGameSnapshot get game => widget.status.peer.minigame!; + String get owner => + '${widget.status.json['board_id']}/${widget.status.json['boot_id']}'; + bool get ready => + widget.connected && game.ready && !pending && release == null; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didUpdateWidget(covariant MiniGamePanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.connected || + oldWidget.status.json['board_id'] != widget.status.json['board_id'] || + oldWidget.status.json['boot_id'] != widget.status.json['boot_id']) { + endHold(); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) endHold(); + } + + @override + void deactivate() { + endHold(); + super.deactivate(); + } + + @override + void dispose() { + endHold(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + void endHold() { + final value = release; + if (value != null && !value.isCompleted) value.complete(); + } + + Future send(String path, Map values) async { + if (!ready || !widget.status.peer.allows(path, values)) return false; + setState(() { + pending = true; + error = null; + }); + try { + final ok = await widget.command(path, values); + if (!ok && mounted) { + setState(() => error = 'The board did not confirm the command.'); + } + return ok; + } catch (e) { + if (mounted) { + setState( + () => error = e is FormatException ? e.message.toString() : '$e', + ); + } + return false; + } finally { + if (mounted) setState(() => pending = false); + } + } + + void beginHold(int key) { + if (!ready) return; + final command = widget.command, heldOwner = owner; + final released = Completer(); + setState(() { + release = released; + pending = true; + error = null; + }); + unawaited(() async { + // Even an uncertain down may have reached the board. Its matching up is + // sequenced after that receipt, never dropped by the single-command gate. + try { + final accepted = await command(minigameInput, { + 'key': '$key', + 'action': 'down', + }); + if (!accepted && !released.isCompleted) released.complete(); + if (mounted) setState(() => pending = false); + await released.future; + } catch (e) { + if (mounted) setState(() => error = '$e'); + } finally { + try { + if (owner == heldOwner) { + final accepted = await command(minigameInput, { + 'key': '$key', + 'action': 'up', + }); + if (!accepted && mounted) { + setState(() => error = 'Key release was not confirmed.'); + } + } + } catch (e) { + if (mounted) setState(() => error = '$e'); + } + if (mounted) { + setState(() { + release = null; + pending = false; + }); + } + } + }()); + } + + Future settings() async { + final initial = game, sourceOwner = owner; + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => MiniGameSettings( + initial: initial, + save: (patch) async { + if (!mounted || + !ready || + owner != sourceOwner || + patch.keys.any( + (key) => !minigameValueMatches( + game.settings[key], + initial.settings[key], + ), + )) { + throw const FormatException( + 'The board or settings changed. Reopen settings.', + ); + } + if (!await send(minigameConfigure, {'settings': jsonEncode(patch)})) { + throw const FormatException( + 'Settings were not confirmed by the board.', + ); + } + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final snapshot = game, colors = Theme.of(context).colorScheme; + final seconds = + snapshot.number( + snapshot.json['timed'] == true ? 'remaining_ms' : 'elapsed_ms', + ) / + 1000; + final phaseTone = snapshot.phase == 'success' + ? const Color(0xff38d996) + : snapshot.phase == 'failed' || snapshot.phase == 'timeout' + ? colors.error + : colors.primary; + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1240), + child: ListView( + key: const PageStorageKey('minigame-workspace-scroll'), + padding: const EdgeInsets.all(20), + children: [ + WorkspaceHeader( + title: 'Game studio', + icon: Icons.sports_esports_outlined, + actions: [ + WorkspaceBadge( + snapshot.phase.replaceAll('-', ' '), + active: widget.connected, + icon: Icons.circle, + ), + ], + ), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.alphaBlend( + colors.primary.withValues(alpha: .09), + colors.surfaceContainerLow, + ), + colors.surfaceContainerLow, + ], + ), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colors.primary.withValues(alpha: .18), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 24, + runSpacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 380, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Game mode', + contentPadding: EdgeInsets.symmetric( + horizontal: 14, + vertical: 4, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + key: const ValueKey('minigame-mode'), + value: snapshot.mode, + isExpanded: true, + itemHeight: 64, + isDense: false, + borderRadius: BorderRadius.circular(16), + items: [ + for (final entry in snapshot.modes.entries) + DropdownMenuItem( + value: entry.key, + child: _GameModeChoice( + id: entry.key, + label: entry.value, + description: snapshot.description( + entry.key, + ), + ), + ), + ], + onChanged: !ready + ? null + : (mode) { + if (mode != null && + mode != snapshot.mode) { + unawaited( + send(minigameConfigure, { + 'settings': jsonEncode({ + 'mode': mode, + }), + }), + ); + } + }, + ), + ), + ), + ), + if (snapshot.json['timed'] == true || + !const [ + 'press-to-win', + 'toggle', + ].contains(snapshot.mode)) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + snapshot.json['timed'] == true + ? Icons.timer_outlined + : Icons.av_timer, + color: phaseTone, + size: 22, + ), + const SizedBox(width: 9), + Flexible( + child: Text( + '${snapshot.json['timed'] == true ? 'Remaining' : 'Elapsed'}: ${seconds.toStringAsFixed(1)} s', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final action in [ + 'start', + 'pause', + 'resume', + 'reset', + 'stop', + ]) + FilledButton.tonalIcon( + key: ValueKey('minigame-$action'), + style: FilledButton.styleFrom( + minimumSize: const Size(96, 42), + backgroundColor: action == 'start' + ? colors.primary + : action == 'reset' + ? colors.primary.withValues(alpha: .13) + : colors.surfaceContainerHigh, + foregroundColor: action == 'start' + ? colors.onPrimary + : action == 'reset' + ? colors.primary + : colors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: + ready && + switch (action) { + 'start' => + !snapshot.running && + snapshot.phase != 'paused', + 'pause' => snapshot.running, + 'resume' => snapshot.phase == 'paused', + _ => true, + } + ? () => send(minigameControl, {'action': action}) + : null, + icon: Icon(switch (action) { + 'start' || 'resume' => Icons.play_arrow_rounded, + 'pause' => Icons.pause_rounded, + 'reset' => Icons.restart_alt_rounded, + _ => Icons.stop_rounded, + }, size: 20), + label: Text( + '${action[0].toUpperCase()}${action.substring(1)}', + ), + ), + TextButton.icon( + onPressed: ready ? settings : null, + icon: const Icon(Icons.tune, size: 19), + label: const Text('Game settings'), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + WorkspaceBadge( + 'Round: ${snapshot.number('round')}', + icon: Icons.flag_outlined, + ), + WorkspaceBadge( + 'Progress: ${snapshot.number('progress')}', + icon: Icons.check_circle_outline, + ), + WorkspaceBadge( + 'Mistakes: ${snapshot.number('mistakes')}', + icon: Icons.close_rounded, + ), + if (snapshot.number('winner') > 0) + WorkspaceBadge( + 'Winner: ${snapshot.number('winner')}', + active: true, + icon: Icons.emoji_events_outlined, + ), + if (snapshot.number('target') > 0) + WorkspaceBadge( + 'Target: ${snapshot.number('target')}', + active: true, + icon: Icons.my_location, + ), + if (snapshot.mode == 'color-match') + WorkspaceBadge( + 'Matched: ${snapshot.number('progress')}', + icon: Icons.palette_outlined, + ), + if (snapshot.number('last_reaction_ms') > 0) + WorkspaceBadge( + 'Reaction: ${snapshot.number('last_reaction_ms')} ms', + icon: Icons.bolt, + ), + if (snapshot.number('best_reaction_ms') > 0) + WorkspaceBadge( + 'Best: ${snapshot.number('best_reaction_ms')} ms', + icon: Icons.speed, + ), + if (snapshot.number('warning') > 0) + WorkspaceBadge( + snapshot.number('warning') > 1 + ? 'Urgent' + : 'Time warning', + icon: Icons.timer_outlined, + ), + ], + ), + SizedBox( + height: 3, + child: pending + ? const LinearProgressIndicator(minHeight: 2) + : null, + ), + ], + ), + ), + const SizedBox(height: 18), + KeyBoxDesigner( + status: widget.status, + workspace: widget.workspace, + enabled: ready && snapshot.acceptsInput, + onTap: (key) => + send(minigameInput, {'key': '$key', 'action': 'tap'}), + onHoldStart: snapshot.usesHold ? beginHold : null, + onHoldEnd: snapshot.usesHold ? endHold : null, + openSettings: widget.openBoxSettings, + inputKeyPrefix: 'minigame-key', + ), + if (!widget.connected) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text('Reconnect to control the game.'), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(error!, style: TextStyle(color: colors.error)), + ), + ], + ), + ), + ); + } +} + +class _GameModeChoice extends StatelessWidget { + const _GameModeChoice({ + required this.id, + required this.label, + required this.description, + }); + final String id, label, description; + @override + Widget build(BuildContext context) => Row( + children: [ + Icon( + switch (id) { + 'press-to-win' => Icons.emoji_events_outlined, + 'illuminated' => Icons.my_location, + 'color-match' => Icons.palette_outlined, + 'simon' => Icons.queue_music, + 'memory-pairs' => Icons.style_outlined, + 'clear-all' => Icons.checklist_rounded, + 'color-order' => Icons.sort_rounded, + 'switch-pattern' => Icons.grid_view_rounded, + 'reaction-chase' => Icons.bolt_rounded, + 'toggle' => Icons.toggle_on_outlined, + _ => Icons.sports_esports_outlined, + }, + size: 23, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text( + description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); +} + +/// Adapted from PCController EffectEditor.number/numberDrafts/save: invalid text +/// stays local, numeric values are bounded, and only explicit save commits. +class MiniGameSettings extends StatefulWidget { + const MiniGameSettings({ + super.key, + required this.initial, + required this.save, + }); + final MiniGameSnapshot initial; + final Future Function(Map) save; + @override + State createState() => _MiniGameSettingsState(); +} + +class _MiniGameSettingsState extends State { + final form = GlobalKey(); + final patch = {}; + late final draft = Map.from( + jsonDecode(jsonEncode(widget.initial.settings)) as Map, + ); + bool saving = false; + bool dirty = false, dismissing = false; + String? error; + static const labels = { + 'limit_ms': 'Time limit (ms; 0 = untimed)', + 'warning_ms': 'Warning remaining (ms)', + 'urgent_ms': 'Urgent remaining (ms)', + 'penalty_ms': 'Mistake penalty (ms)', + 'window_ms': 'Input window (ms)', + 'show_ms': 'Show time (ms)', + 'gap_ms': 'Gap (ms)', + 'feedback_ms': 'Feedback time (ms)', + 'intro_ms': 'Reveal fade per box (ms)', + 'preview_ms': 'Pairs preview hold (ms)', + 'mismatch_ms': 'Wrong pair feedback (ms)', + 'rounds': 'Rounds', + 'sequence_length': 'Sequence length', + 'palette_size': 'Palette size', + 'fixed_target': 'Fixed target (0 = random)', + }; + void change(String key, Object value) => setState(() { + dirty = true; + draft[key] = value; + patch[key] = value; + }); + Future dismiss() async { + if (saving || dismissing) return; + dismissing = true; + try { + if (dirty) { + final discard = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Unsaved game settings'), + content: const Text('Discard these changes?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Keep editing'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Discard changes'), + ), + ], + ), + ); + if (discard != true) return; + } + if (mounted) Navigator.pop(context); + } finally { + dismissing = false; + } + } + + Future save() async { + if (saving || !form.currentState!.validate()) return; + if (patch.isEmpty) { + Navigator.pop(context); + return; + } + setState(() { + saving = true; + error = null; + }); + try { + widget.initial.command(minigameConfigure, { + 'settings': jsonEncode(patch), + }); + await widget.save(patch); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + setState(() { + saving = false; + error = '$e'; + }); + } + } + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(dismiss()); + }, + child: AlertDialog( + title: const Text('Game settings'), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Saving resets the game to Ready.'), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final entry in minigameLimits.entries) + if (draft[entry.key] is int) + SizedBox( + width: 230, + child: TextFormField( + key: ValueKey('minigame-setting-${entry.key}'), + enabled: !saving, + initialValue: '${draft[entry.key]}', + decoration: InputDecoration( + labelText: labels[entry.key], + ), + keyboardType: TextInputType.number, + validator: (text) { + final n = int.tryParse(text ?? ''); + return n == null || + n < entry.value.$1 || + n > entry.value.$2 + ? 'Enter ${entry.value.$1}–${entry.value.$2}' + : null; + }, + onChanged: (text) { + final n = int.tryParse(text); + if (n != null) { + change(entry.key, n); + } else { + setState(() => dirty = true); + } + }, + ), + ), + ], + ), + for (final entry in { + 'intro_enabled': 'Reveal colors and notes before play', + 'color_arpeggio': 'Arpeggio color notes', + }.entries) + if (draft[entry.key] is bool && + (entry.key != 'intro_enabled' || + const [ + 'color-match', + 'memory-pairs', + ].contains(draft['mode']))) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(entry.value), + value: draft[entry.key] as bool, + onChanged: saving ? null : (v) => change(entry.key, v), + ), + if (draft['hard'] is bool) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Hard mode'), + value: draft['hard'] as bool, + onChanged: saving ? null : (v) => change('hard', v), + ), + if (const [ + 'toggle', + 'latch', + 'press', + ].contains(draft['toggle_kind'])) + DropdownButtonFormField( + initialValue: draft['toggle_kind'] as String, + decoration: const InputDecoration( + labelText: 'Toggle behavior', + ), + items: [ + for (final v in ['toggle', 'latch', 'press']) + DropdownMenuItem(value: v, child: Text(v)), + ], + onChanged: saving + ? null + : (v) { + if (v != null) change('toggle_kind', v); + }, + ), + if (validOutputs(draft['outputs'])) ...[ + const SizedBox(height: 20), + const Text('Output mapping'), + for (var i = 0; i < 8; i++) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: + (draft['outputs'] as List)[i]['source'] + as String, + decoration: InputDecoration( + labelText: 'Output ${i + 1}', + ), + items: [ + for (final v in minigameOutputSources) + DropdownMenuItem(value: v, child: Text(v)), + ], + onChanged: saving + ? null + : (v) { + if (v == null) return; + final outputs = draft['outputs'] as List; + final key = outputs[i]['key'] as int; + outputs[i] = { + 'source': v, + 'key': + minigameOutputSources.indexOf(v) < 4 + ? 0 + : key.clamp(1, 9), + }; + change('outputs', outputs); + }, + ), + ), + if (minigameOutputSources.indexOf( + (draft['outputs'] as List)[i]['source'] + as String, + ) >= + 4) ...[ + const SizedBox(width: 12), + SizedBox( + width: 100, + child: DropdownButtonFormField( + key: ValueKey( + 'output-key-$i-${(draft['outputs'] as List)[i]['key']}', + ), + initialValue: + (draft['outputs'] as List)[i]['key'] as int, + decoration: const InputDecoration( + labelText: 'Key', + ), + items: [ + for (var k = 1; k <= 9; k++) + DropdownMenuItem( + value: k, + child: Text('$k'), + ), + ], + onChanged: saving + ? null + : (v) { + if (v == null) return; + final outputs = + draft['outputs'] as List; + outputs[i] = { + ...outputs[i] as Map, + 'key': v, + }; + change('outputs', outputs); + }, + ), + ), + ], + ], + ), + ), + ], + if (error != null) + Text( + error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: saving ? null : dismiss, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving ? null : save, + child: Text(saving ? 'Saving…' : 'Save to board'), + ), + ], + ), + ); +} diff --git a/mobile/lib/network_tools_page.dart b/mobile/lib/network_tools_page.dart new file mode 100644 index 00000000..4113cfa5 --- /dev/null +++ b/mobile/lib/network_tools_page.dart @@ -0,0 +1,423 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'api.dart'; +import 'workspace_widgets.dart'; + +class NetworkToolsPage extends StatefulWidget { + const NetworkToolsPage({super.key, required this.api}); + final PakarApi api; + @override + State createState() => _NetworkToolsPageState(); +} + +class _NetworkToolsPageState extends State { + final ntp = TextEditingController(), + date = TextEditingController(), + ip = TextEditingController(); + final interval = TextEditingController(), + target = TextEditingController(text: 'pool.ntp.org'); + late final owner = widget.api.connectionRevision; + Map? snapshot; + Map baseline = {}; + bool enabled = true, + dhcp = true, + dateAuto = false, + ipAuto = false, + busy = false, + dirty = false; + String? error; + StreamSubscription? changes; + StreamSubscription? connection; + StreamSubscription>? observations; + Timer? tick; + final observationAge = Stopwatch(); + int readGeneration = 0; + bool get editable => current && !busy && snapshot != null; + bool get current => + mounted && + widget.api.connectionRevision == owner && + widget.api.connectionWanted && + widget.api.appForeground; + + @override + void initState() { + super.initState(); + connection = widget.api.connectionUpdates.listen((_) { + if (mounted) setState(() {}); + }); + changes = widget.api.configurationUpdates.listen((_) { + if (current && !busy) unawaited(load()); + }); + observations = widget.api.networkObservations.listen((_) { + if (current && !busy) unawaited(load()); + }); + unawaited(load()); + tick = Timer.periodic(const Duration(seconds: 1), (_) { + if (current && snapshot != null) setState(() {}); + }); + } + + void accept(Map value, {bool replaceDraft = false}) { + if (!current) return; + final settings = value['settings']; + if (settings is! Map || + value['clock'] is! Map || + !const [ + 'ntp_enabled', + 'ntp_dhcp', + 'http_date_automatic', + 'public_ip_automatic', + ].every((key) => settings[key] is bool) || + !const [ + 'ntp_server', + 'http_date_url', + 'public_ip_url', + ].every((key) => settings[key] is String) || + settings['refresh_seconds'] is! int) { + throw const FormatException('Incomplete network tools snapshot'); + } + setState(() { + snapshot = value; + observationAge + ..reset() + ..start(); + if (!dirty || replaceDraft) { + baseline = Map.from(settings); + enabled = settings['ntp_enabled'] == true; + dhcp = settings['ntp_dhcp'] == true; + dateAuto = settings['http_date_automatic'] == true; + ipAuto = settings['public_ip_automatic'] == true; + ntp.text = '${settings['ntp_server']}'; + date.text = '${settings['http_date_url']}'; + ip.text = '${settings['public_ip_url']}'; + interval.text = '${settings['refresh_seconds']}'; + dirty = false; + } + error = null; + }); + } + + Future load() async { + final generation = ++readGeneration; + try { + final value = await widget.api.getJson('/api/network/tools'); + if (generation == readGeneration) accept(value); + } on Object catch (e) { + if (current && generation == readGeneration) setState(() => error = '$e'); + } + } + + Future run(Future Function() action) async { + if (!editable) return; + readGeneration++; + setState(() { + busy = true; + error = null; + }); + try { + await action(); + } on Object catch (e) { + if (current) setState(() => error = '$e'); + } finally { + if (mounted) setState(() => busy = false); + } + } + + Future save() => run(() async { + final seconds = int.tryParse(interval.text); + if (seconds == null || seconds < 60 || seconds > 86400) { + throw const FormatException('Refresh interval must be 60–86400 seconds'); + } + final requested = { + 'ntp_enabled': enabled, + 'ntp_dhcp': dhcp, + 'ntp_server': ntp.text.trim(), + 'http_date_url': date.text.trim(), + 'http_date_automatic': dateAuto, + 'public_ip_url': ip.text.trim(), + 'public_ip_automatic': ipAuto, + 'refresh_seconds': seconds, + }; + final patch = { + for (final entry in requested.entries) + if (baseline[entry.key] != entry.value) entry.key: entry.value, + }; + if (patch.isEmpty) { + accept(snapshot!, replaceDraft: true); + return; + } + accept( + await widget.api.networkToolsWrite('/api/network/tools', patch), + replaceDraft: true, + ); + }); + + Future probe(String kind) => run(() async { + accept( + await widget.api.networkToolsWrite('/api/network/probe', { + 'kind': kind, + if (kind == 'dns' || kind == 'ping') 'target': target.text.trim(), + }), + ); + final revision = (snapshot?['job'] as Map?)?['revision']; + // Events finish the job on WebSocket. Bounded read-only polling supports + // USB and an HTTP-only connection; it never resends the probe command. + for (var n = 0; n < 24 && current; n++) { + if ((snapshot?['job'] as Map?)?['busy'] != true) return; + await Future.delayed(const Duration(milliseconds: 500)); + if (!current) return; + accept(await widget.api.getJson('/api/network/tools')); + if ((snapshot?['job'] as Map?)?['revision'] != revision) { + throw const FormatException( + 'Another diagnostic replaced this result; no probe was replayed', + ); + } + } + }); + + @override + void dispose() { + tick?.cancel(); + unawaited(changes?.cancel()); + unawaited(connection?.cancel()); + unawaited(observations?.cancel()); + for (final c in [ntp, date, ip, interval, target]) { + c.dispose(); + } + super.dispose(); + } + + Widget field( + TextEditingController controller, + String label, { + bool number = false, + }) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + enabled: editable, + keyboardType: number ? TextInputType.number : TextInputType.url, + decoration: InputDecoration(labelText: label), + onChanged: (_) => setState(() => dirty = true), + ), + ); + + @override + Widget build(BuildContext context) { + final clock = snapshot?['clock'] as Map?, + observed = snapshot?['observed'] as Map?; + final job = snapshot?['job'] as Map?; + final epoch = clock?['utc_epoch']; + final utc = epoch is int && clock?['valid'] == true + ? DateTime.fromMillisecondsSinceEpoch( + epoch * 1000 + observationAge.elapsedMilliseconds, + isUtc: true, + ).toIso8601String() + : 'Not synchronized'; + return PopScope( + canPop: !dirty && !busy, + onPopInvokedWithResult: (didPop, result) async { + if (didPop || busy) return; + final choice = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unsaved network settings'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'cancel'), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'discard'), + child: const Text('Discard'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, 'save'), + child: const Text('Save'), + ), + ], + ), + ); + if (choice == 'save') await save(); + if (mounted && (choice == 'discard' || choice == 'save' && !dirty)) { + setState(() => dirty = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.pop(context); + }); + } + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + busy + ? 'Working…' + : dirty + ? 'Unsaved changes' + : 'Board network tools', + ), + ), + IconButton( + tooltip: 'Refresh observed values', + onPressed: busy ? null : load, + icon: const Icon(Icons.refresh), + ), + FilledButton.icon( + onPressed: dirty && editable ? save : null, + icon: const Icon(Icons.save_outlined), + label: const Text('Save'), + ), + ], + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.all(12), + child: Text( + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + WorkspaceSection( + title: 'Board time', + icon: Icons.schedule, + children: [ + SelectableText(utc), + Text( + 'Source: ${clock?['source'] ?? 'Unknown'} · read ${observationAge.elapsed.inSeconds}s ago', + ), + SwitchListTile.adaptive( + title: const Text('NTP synchronization'), + value: enabled, + onChanged: !editable + ? null + : (v) => setState(() { + enabled = v; + dirty = true; + }), + ), + SwitchListTile.adaptive( + title: const Text('Accept DHCP NTP servers'), + value: dhcp, + onChanged: !editable + ? null + : (v) => setState(() { + dhcp = v; + dirty = true; + }), + ), + field(ntp, 'NTP server / fallback'), + field(date, 'HTTP Date service (HTTPS)'), + SwitchListTile.adaptive( + title: const Text('Automatically verify HTTP time'), + value: dateAuto, + onChanged: !editable + ? null + : (v) => setState(() { + dateAuto = v; + dirty = true; + }), + ), + OutlinedButton.icon( + onPressed: editable ? () => probe('http-date') : null, + icon: const Icon(Icons.fact_check_outlined), + label: const Text('Verify HTTP date'), + ), + ], + ), + const SizedBox(height: 16), + WorkspaceSection( + title: 'Internet address', + icon: Icons.public, + children: [ + SelectableText( + observed?['public_ip_observed'] == true + ? '${observed?['public_ip']}' + : 'Not observed', + ), + if (observed?['public_ip_observed'] == true) + Text( + 'Observation age: ${((observed?['public_ip_age_ms'] as num? ?? 0) + observationAge.elapsedMilliseconds) ~/ 1000} s', + ), + field(ip, 'Public IPv4 lookup (HTTPS)'), + field( + interval, + 'Automatic refresh interval (seconds)', + number: true, + ), + SwitchListTile.adaptive( + title: const Text('Automatically refresh public IP'), + value: ipAuto, + onChanged: !editable + ? null + : (v) => setState(() { + ipAuto = v; + dirty = true; + }), + ), + OutlinedButton.icon( + onPressed: editable ? () => probe('public-ip') : null, + icon: const Icon(Icons.travel_explore), + label: const Text('Look up public IP'), + ), + ], + ), + const SizedBox(height: 16), + WorkspaceSection( + title: 'Network diagnostics', + icon: Icons.network_check, + children: [ + field(target, 'Hostname or IPv4 address'), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: editable ? () => probe('dns') : null, + icon: const Icon(Icons.dns_outlined), + label: const Text('DNS query'), + ), + OutlinedButton.icon( + onPressed: editable ? () => probe('ping') : null, + icon: const Icon(Icons.speed), + label: const Text('Ping ×3'), + ), + ], + ), + if (job != null) ...[ + const SizedBox(height: 12), + Text('${job['kind']} · ${job['phase']}'), + if ('${job['value'] ?? ''}'.isNotEmpty) + SelectableText('${job['value']}'), + if ('${job['error'] ?? ''}'.isNotEmpty) + Text( + '${job['error']}', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + if (job['kind'] == 'ping') + Text( + '${job['ping_received']}/${job['ping_sent']} replies · ${job['ping_last_rtt_ms']} ms', + ), + ], + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/output_panel.dart b/mobile/lib/output_panel.dart index 081f193b..df655149 100644 --- a/mobile/lib/output_panel.dart +++ b/mobile/lib/output_panel.dart @@ -5,16 +5,20 @@ import 'package:flutter/material.dart'; import 'game_presentation_editor.dart'; import 'game_settings_command.dart'; import 'state_appearance_page.dart'; +import 'color_picker.dart'; import 'icons.dart'; import 'models.dart'; -import 'key_pixel_preview.dart'; import 'peer_context.dart'; import 'pixel_diagnostic.dart'; import 'table_workspace.dart'; import 'workspace_widgets.dart'; import 'led_backend.dart'; import 'led_backend_control.dart'; +import 'minigame_panel.dart'; +import 'key_box_designer.dart'; + +enum OutputPanelSection { output, game } /// Pealayer layout.rs: hardware_channel_drag_handle/drop_target and /// effect_library_card_header: stable keyed cards, a reserved action gutter, @@ -30,6 +34,8 @@ class OutputPanel extends StatefulWidget { this.workspace, this.verifiedCommand, this.readStatus, + this.statusUpdates, + this.section = OutputPanelSection.output, }); final DeviceStatus? status; final bool connected; @@ -38,19 +44,22 @@ class OutputPanel extends StatefulWidget { final TableWorkspace? workspace; final Future Function(String, Map)? verifiedCommand; final Future Function()? readStatus; + final Stream? statusUpdates; + final OutputPanelSection section; @override State createState() => _OutputPanelState(); } class _OutputPanelState extends State { late final workspace = widget.workspace ?? TableWorkspace(); - bool arranging = false, arrangingKeys = false, pending = false; + bool arranging = false, pending = false; final modes = []; String? driver, outputMode; bool? outputEnabled; double? brightnessDraft; String? presentedOwner; bool hasSceneContent = false; + bool firstWins = false; void remember() { final peer = widget.status?.peer; @@ -77,6 +86,7 @@ class _OutputPanelState extends State { outputMode = widget.status?.ledOutputMode ?? outputMode; outputEnabled = widget.status?.ledOutputEnabled ?? outputEnabled; if (peer?.known == true) { + firstWins = peer?.role == 'first-wins'; hasSceneContent = peer?.role == 'first-wins' || modes.length > 1 || @@ -106,6 +116,65 @@ class _OutputPanelState extends State { bool get ready => widget.connected && !pending; bool get ledsReady => ready && widget.status?.peer.has('leds') == true; + + String? gameBlockedReason([int? key]) { + final status = widget.status; + if (!widget.connected || status == null) { + return 'Reconnect to control the game.'; + } + if (pending) return 'A command is still pending.'; + if (status.json['programming_latch'] == true) { + return 'AVR programming is active.'; + } + if (!status.avrOnline) return 'The AVR is offline.'; + if (status.json['key_mask_applied'] != true) { + return 'Waiting for AVR to apply enabled boxes.'; + } + if (key != null && !keyEnabled(key)) { + return 'Box $key is disabled in Game settings.'; + } + final path = key == null ? '/api/game/reset' : '/api/game/press'; + if (!status.peer.allows(path, key == null ? const {} : {'key': '$key'})) { + return key == null + ? 'This board has not enabled game reset.' + : 'This board has not enabled remote button presses.'; + } + return null; + } + + void explain(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + Future gameCommand([int? key]) async { + final reason = gameBlockedReason(key); + if (reason != null) { + explain(reason); + return; + } + final path = key == null ? '/api/game/reset' : '/api/game/press'; + final values = key == null ? {} : {'key': '$key'}; + setState(() => pending = true); + try { + if (widget.verifiedCommand != null) { + if (!await widget.verifiedCommand!(path, values)) { + explain( + 'The board did not confirm the command. It was not replayed.', + ); + } + } else { + await widget.command(path, values); + } + } catch (error) { + explain(error.toString()); + } finally { + if (mounted) setState(() => pending = false); + } + } + Future send( String path, [ Map values = const {}, @@ -174,6 +243,7 @@ class _OutputPanelState extends State { status: source, command: widget.verifiedCommand!, readStatus: widget.readStatus!, + statusUpdates: widget.statusUpdates, ids: '1-18', ), ), @@ -379,6 +449,27 @@ class _OutputPanelState extends State { labelText: entry.value, prefixText: '#', counterText: '', + suffixIcon: IconButton( + tooltip: 'Choose color', + icon: const Icon(Icons.palette_outlined), + onPressed: () async { + final next = await showLedColorPicker( + context, + initial: + int.tryParse( + colors[entry.key]!.text, + radix: 16, + ) ?? + 0, + title: entry.value, + ); + if (next != null && context.mounted) { + update( + () => colors[entry.key]!.text = rgbHex(next), + ); + } + }, + ), errorText: RegExp(r'^[0-9a-fA-F]{6}$') .hasMatch(colors[entry.key]!.text) @@ -488,30 +579,13 @@ class _OutputPanelState extends State { return mask is int && mask & (1 << (key - 1)) != 0; } - Color keyColor(int key) { - final config = widget.status?.json['key_game']; - final winner = widget.status?.winner ?? 0; - final field = winner == 0 - ? 'idle_rgb' - : winner == key - ? 'win_rgb' - : 'lose_rgb'; - final fallback = winner == 0 - ? 0xffffff - : winner == key - ? 0x00ff00 - : 0xff0000; - final value = config is Map ? config[field] : null; - return Color(0xff000000 | (value is int ? value : fallback)); - } - Widget card(String id, int index) { final status = widget.status; final backend = status == null ? null : LedBackendState.fromStatus(status.json); final (title, icon) = switch (id) { - 'scene' => ('Scene', PakarIcons.gridFour), + 'scene' => (firstWins ? 'Boxes' : 'Scene', PakarIcons.gridFour), 'output' => ('LED output', PakarIcons.gauge), _ => ('Pixel diagnostics', PakarIcons.circuitry), }; @@ -544,190 +618,16 @@ class _OutputPanelState extends State { style: Theme.of(context).textTheme.titleMedium, ), ), - if (status?.peer.role == 'first-wins') - Row( - children: [ - FilterChip( - avatar: const Icon(Icons.open_with, size: 18), - label: const Text('Arrange boxes'), - selected: arrangingKeys, - onSelected: (value) => setState(() => arrangingKeys = value), - ), - const SizedBox(width: 8), - if (arrangingKeys) - TextButton.icon( - icon: const Icon(Icons.restart_alt), - label: const Text('Reset positions'), - onPressed: () => setState(workspace.resetKeyPositions), - ), - ], - ), - if (status?.peer.role == 'first-wins') - LayoutBuilder( - builder: (context, bounds) { - const size = 88.0, height = 300.0; - final width = bounds.maxWidth.isFinite - ? bounds.maxWidth - : 360.0; - final maxX = width > size ? width - size : 0.0; - const maxY = height - size; - return Container( - key: const ValueKey('key-layout-canvas'), - height: height, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: arrangingKeys - ? Theme.of(context).colorScheme.primary - : Theme.of(context).dividerColor, - ), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - for (var key = 1; key <= 9; key++) - AnimatedPositioned( - key: ValueKey('game-key-position-$key'), - duration: arrangingKeys - ? Duration.zero - : const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, - left: workspace.keyPosition(key).dx * maxX, - top: workspace.keyPosition(key).dy * maxY, - width: size, - height: size, - child: Semantics( - label: 'Key $key', - selected: status?.winner == key, - child: GestureDetector( - key: ValueKey('game-key-drag-$key'), - behavior: HitTestBehavior.opaque, - onTap: - !arrangingKeys && - ready && - keyEnabled(key) && - status?.peer.has('game-press') == true && - status?.avrOnline == true && - status?.json['key_mask_applied'] == true - ? () => - send('/api/game/press', {'key': '$key'}) - : null, - onPanUpdate: !arrangingKeys - ? null - : (details) { - final current = workspace.keyPosition( - key, - ); - workspace.moveKey( - key, - Offset( - maxX == 0 - ? 0 - : current.dx + - details.delta.dx / maxX, - current.dy + details.delta.dy / maxY, - ), - ); - setState(() {}); - }, - child: MouseRegion( - cursor: arrangingKeys - ? SystemMouseCursors.move - : MouseCursor.defer, - child: Opacity( - opacity: keyEnabled(key) ? 1 : 0.3, - child: AnimatedContainer( - key: ValueKey('game-key-$key'), - duration: const Duration(milliseconds: 120), - alignment: Alignment.center, - decoration: BoxDecoration( - color: Color.alphaBlend( - keyColor(key).withValues(alpha: 0.3), - Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), - borderRadius: BorderRadius.circular(20), - border: Border.all( - width: arrangingKeys ? 2 : 3, - color: arrangingKeys - ? Theme.of(context) - .colorScheme - .outlineVariant - : status?.peer.has('inputs') == - true && - (key <= 8 - ? ((status?.activeInputs ?? - 0) & - (1 << - (key - - 1))) != - 0 - : status?.json['input_9'] == - true) - ? Theme.of(context) - .colorScheme - .primary - : Colors.transparent, - ), - ), - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - if (arrangingKeys) - const Icon( - Icons.drag_indicator, - size: 16, - ), - Text( - '$key', - style: Theme.of(context) - .textTheme - .headlineSmall, - ), - for (var group = 0; group < 2; group++) - SizedBox( - height: 6, - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - for (final rgb - in keyPixelGroup( - status, - key, - group, - ) ?? - const []) - Expanded( - child: Container( - height: 4, - margin: - const EdgeInsets.only( - right: 1, - ), - color: Color( - 0xff000000 | rgb, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ], - ), - ); - }, + if (status?.peer.role == 'first-wins' && status != null) + KeyBoxDesigner( + status: status, + workspace: workspace, + enabled: gameBlockedReason() == null, + onTap: gameCommand, + onBlockedTap: gameCommand, + onReset: gameCommand, + openSettings: gameSettings, + blockedReason: gameBlockedReason(), ), if (modes.length > 1) Wrap( @@ -753,14 +653,7 @@ class _OutputPanelState extends State { child: FilledButton.icon( icon: const Icon(PakarIcons.arrowCounterClockwise), label: const Text('Reset to white'), - onPressed: - ready && - status?.peer.modes.any((m) => m.id == 'first-wins') == - true && - status?.avrOnline == true && - status?.json['key_mask_applied'] == true - ? () => send('/api/game/reset') - : null, + onPressed: () => gameCommand(), ), ), if (status?.peer.has('key-settings') == true && @@ -914,26 +807,49 @@ class _OutputPanelState extends State { @override Widget build(BuildContext context) { - final visibleCards = workspace.outputCards - .where((id) => id != 'scene' || hasSceneContent) - .toList(); + final game = widget.section == OutputPanelSection.game; + if (game && + widget.status?.json['minigame'] != null && + widget.verifiedCommand == null) { + return const Center(child: Text('Waiting for minigame readiness.')); + } + if (game && + widget.status?.peer.minigame != null && + widget.verifiedCommand != null) { + return MiniGamePanel( + key: ValueKey('minigame-${widget.status!.json['board_id']}'), + status: widget.status!, + connected: widget.connected, + command: widget.verifiedCommand!, + openBoxSettings: gameSettings, + workspace: workspace, + ); + } + final visibleCards = game + ? [if (firstWins) 'scene'] + : workspace.outputCards + .where((id) => id != 'scene' || (hasSceneContent && !firstWins)) + .toList(); return Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1120), child: ReorderableListView.builder( - key: const PageStorageKey('output-panel-scroll'), + key: PageStorageKey( + game ? 'game-panel-scroll' : 'output-panel-scroll', + ), padding: const EdgeInsets.all(20), buildDefaultDragHandles: false, header: WorkspaceHeader( - title: 'Output', - icon: PakarIcons.gauge, + title: game ? 'Game' : 'Output', + icon: game ? PakarIcons.play : PakarIcons.gauge, actions: [ - FilterChip( - label: const Text('Arrange panels'), - selected: arranging, - onSelected: (value) => setState(() => arranging = value), - ), + if (!game) + FilterChip( + label: const Text('Arrange panels'), + selected: arranging, + onSelected: (value) => setState(() => arranging = value), + ), ], ), itemCount: visibleCards.length, diff --git a/mobile/lib/panel_inventory.dart b/mobile/lib/panel_inventory.dart index 5587cd7c..df1ae1c8 100644 --- a/mobile/lib/panel_inventory.dart +++ b/mobile/lib/panel_inventory.dart @@ -14,6 +14,10 @@ class PanelInventory { bool has(String feature) => _features.contains(feature); Set get features => Set.unmodifiable(_features); + // Output belongs to LED-capable boards, not exclusively to table geometry. + // Retention only preserves navigation; fresh capabilities still gate commands. + bool get output => known && has('leds'); + bool get game => known && role == 'first-wins'; bool get frontPanel => has('display') || has('text') || has('buzzer') || has('lcd'); diff --git a/mobile/lib/peer_context.dart b/mobile/lib/peer_context.dart index 6466271b..d7f64eae 100644 --- a/mobile/lib/peer_context.dart +++ b/mobile/lib/peer_context.dart @@ -5,6 +5,7 @@ import 'game_settings_command.dart'; import 'board_artwork.dart'; import 'application_artwork.dart'; import 'led_backend.dart'; +import 'minigame.dart'; /// Actual PCController peripheral-availability.ts ready AND advertised rule, /// adapted through Pakar's living capability manifest (capabilities.rs). @@ -21,6 +22,7 @@ class PeerContext { this.application, this.ledOutput, this.role, + this.minigame, ); static final unknown = PeerContext._( false, @@ -33,6 +35,7 @@ class PeerContext { null, null, '', + null, ); final bool known; final String kind, name, hostname; @@ -42,6 +45,7 @@ class PeerContext { final PeerApplication? application; final LedOutputCapability? ledOutput; final String role; + final MiniGameSnapshot? minigame; bool get tableSelection => table && (role == 'table-selection' || @@ -133,6 +137,7 @@ class PeerContext { ? LedOutputCapability.parse(value['led_output']) : null, role, + keyGame ? MiniGameSnapshot.read(status['minigame']) : null, ); } @@ -142,6 +147,9 @@ class PeerContext { bool allows(String path, Map values) { if (!known) return false; return switch (path) { + '/api/network/tools' || '/api/network/probe' => has('network-tools'), + minigameConfigure || minigameControl || minigameInput => + has('command-ack') && minigame?.allows(path, values) == true, '/api/led/presentation' || '/api/led/preview' => has('state-presentation') && has('leds'), '/api/cell' || '/api/cells/mapping' => tableSelection, @@ -177,6 +185,11 @@ class PeerContext { '/api/brightness' || '/api/effect' => has('leds'), '/api/led/diagnostic' => has('leds') && has('pixel-diagnostic'), '/api/pixels/groups' => has('leds') && has('pixel-group-layout'), + '/api/pixels/corrections/preview' => + has('pixel-corrections-preview') && + has('pixel-corrections-preview-command') && + values.length == 1 && + values.containsKey('configuration'), '/api/device/name' => has('identity'), '/api/network' || '/api/cloud' => has('network'), _ => false, // Add an explicit contract before exposing a new mutation. @@ -245,7 +258,10 @@ class LedOutputCapability { } const knownFeatures = { + 'network-tools', 'state-presentation', + 'game-press', + 'game-presentation', 'key-settings', 'game-melodies', 'usb-key-settings', @@ -255,10 +271,12 @@ const knownFeatures = { 'usb-api', 'status-rgb', 'command-ack', + 'avr-command-ack', 'pixel-diagnostic', 'pixel-diagnostic-effects', 'pixel-diagnostic-overlay', 'pixel-corrections-preview', + 'pixel-corrections-preview-command', 'pixel-group-layout', 'pixel-correction-rule-toggle', 'pixel-corrections', diff --git a/mobile/lib/pixel_correction_address.dart b/mobile/lib/pixel_correction_address.dart index 53e9fd13..2d9e81a2 100644 --- a/mobile/lib/pixel_correction_address.dart +++ b/mobile/lib/pixel_correction_address.dart @@ -1,6 +1,31 @@ import 'models.dart'; import 'pixel_corrections_editor.dart'; +/// Diagnostics select a raw source; appended corrections address an output +/// slot after the saved rules. The inverse can be empty or ambiguous, so callers +/// must not substitute the raw index or silently choose the first destination. +List pixelCorrectionOutputSlots({ + required int raw, + required int pixels, + required Object? sources, +}) { + if (pixels < 1 || + pixels > 480 || + raw < 0 || + raw >= pixels || + sources is! List || + sources.length != pixels || + sources.any( + (source) => source is! int || source < -1 || source >= pixels, + )) { + throw const FormatException('Correction output mapping is unavailable'); + } + return [ + for (var wire = 0; wire < pixels; wire++) + if (sources[wire] == raw) wire, + ]; +} + /// The names/order and physical spans must describe the same observed layout. /// An absent or malformed advertised layout never falls back to six pixels. bool pixelCorrectionMapMatches(DeviceStatus? status, CellMap? mapping) { diff --git a/mobile/lib/pixel_correction_menu.dart b/mobile/lib/pixel_correction_menu.dart index 7ae08e0b..f664e704 100644 --- a/mobile/lib/pixel_correction_menu.dart +++ b/mobile/lib/pixel_correction_menu.dart @@ -1,22 +1,139 @@ import 'package:flutter/material.dart'; +import 'api.dart'; import 'controller_dialog.dart'; +import 'pixel_correction_address.dart'; import 'pixel_corrections_editor.dart'; /// Reuses the imported PCController phone/tablet dialog. Choosing an operation /// creates an editor intent only; persistence and live preview remain explicit. Future showPixelCorrectionMenu( BuildContext context, - int pixel, -) => showDialog( + int pixel, { + required PakarApi api, + required bool Function() authorize, +}) => showDialog( context: context, - builder: (dialog) => ControllerDialog( - title: 'Correct output slot ${pixel + 1}', + builder: (dialog) => + _CorrectionMenu(raw: pixel, api: api, authorize: authorize), +); + +class _CorrectionMenu extends StatefulWidget { + const _CorrectionMenu({ + required this.raw, + required this.api, + required this.authorize, + }); + final int raw; + final PakarApi api; + final bool Function() authorize; + @override + State<_CorrectionMenu> createState() => _CorrectionMenuState(); +} + +class _CorrectionMenuState extends State<_CorrectionMenu> { + int? wire, revision; + int pixels = 0; + List? outputs; + String? error; + @override + void initState() { + super.initState(); + load(); + } + + Future load() async { + try { + if (!widget.authorize()) { + throw const FormatException('Device connection changed.'); + } + final value = await widget.api.getJson('/api/pixels/corrections'); + if (!mounted) return; + if (!widget.authorize()) { + throw const FormatException('Device connection changed.'); + } + if (value['pixels'] is! int || + value['revision'] is! int || + value['revision'] < 0 || + value['revision'] > 0xffffffff || + (value.containsKey('index_base') && value['index_base'] != 0)) { + throw const FormatException('Correction output mapping is unavailable'); + } + if (value['preview'] is Map && value['preview']['active'] == true) { + throw const FormatException( + 'Stop the active correction preview before selecting an output slot.', + ); + } + final matches = pixelCorrectionOutputSlots( + raw: widget.raw, + pixels: value['pixels'] as int, + sources: value['sources'], + ); + setState(() { + pixels = value['pixels'] as int; + revision = value['revision'] as int; + outputs = matches; + wire = matches.length == 1 ? matches.single : null; + }); + } catch (failure) { + if (mounted) setState(() => error = failure.toString()); + } + } + + @override + Widget build(BuildContext context) => ControllerDialog( + title: wire == null + ? 'Correct source pixel ${widget.raw + 1}' + : 'Correct output slot ${wire! + 1}', icon: Icons.tune_rounded, maxWidth: 520, child: Column( mainAxisSize: MainAxisSize.min, children: [ + Text('Diagnostic source ${widget.raw + 1}'), + if (error != null) + Text( + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (outputs == null && error == null) + const Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(), + ), + if (outputs != null && outputs!.length > 1) + DropdownButtonFormField( + key: const Key('correction-output-choice'), + initialValue: wire, + decoration: const InputDecoration(labelText: 'Output slot'), + hint: const Text('Choose an output slot'), + items: [ + for (final index in outputs!) + DropdownMenuItem( + value: index, + child: Text('Output slot ${index + 1}'), + ), + ], + onChanged: (value) => setState(() => wire = value), + ), + if (outputs != null && outputs!.isEmpty) ...[ + const Text( + 'This source is not present in the saved output. Choose the wire slot to correct.', + ), + TextFormField( + key: const Key('correction-output-choice'), + decoration: InputDecoration(labelText: 'Output slot (1–$pixels)'), + keyboardType: TextInputType.number, + onChanged: (text) { + final value = int.tryParse(text); + setState( + () => wire = value != null && value >= 1 && value <= pixels + ? value - 1 + : null, + ); + }, + ), + ], for (final option in [ ( 'black', @@ -52,21 +169,28 @@ Future showPixelCorrectionMenu( contentPadding: const EdgeInsets.symmetric(horizontal: 4), leading: Icon( option.$3, - color: Theme.of(dialog).colorScheme.primary, + color: Theme.of(context).colorScheme.primary, ), title: Text(option.$4), subtitle: Text(option.$5), trailing: const Icon(Icons.chevron_right, size: 20), - onTap: () => Navigator.pop( - dialog, - PixelCorrectionRuleIntent( - kind: option.$1, - pixel: pixel, - offset: option.$2, - ), - ), + enabled: wire != null && widget.authorize(), + onTap: wire == null + ? null + : () { + if (!widget.authorize()) return; + Navigator.pop( + context, + PixelCorrectionRuleIntent( + kind: option.$1, + pixel: wire!, + offset: option.$2, + revision: revision, + ), + ); + }, ), ], ), - ), -); + ); +} diff --git a/mobile/lib/pixel_correction_preview.dart b/mobile/lib/pixel_correction_preview.dart index a5e29c8c..eb1ac5a4 100644 --- a/mobile/lib/pixel_correction_preview.dart +++ b/mobile/lib/pixel_correction_preview.dart @@ -107,15 +107,8 @@ class PixelCorrectionPreview extends ChangeNotifier { .map((value) => Map.from(value as Map)) .toList(), ); - final reply = await api.postJson( - path, - { - 'configuration': jsonEncode({ - ...config, - 'ttl_ms': ttlMs, - 'lease_id': ?issuedLease, - }), - }, + final reply = await api.previewCorrections( + {...config, 'ttl_ms': ttlMs, 'lease_id': ?issuedLease}, authorize: () => !_disposed && enabled && @@ -226,12 +219,10 @@ class PixelCorrectionPreview extends ChangeNotifier { api.freshStatus?.json['board_id'] == board && api.freshStatus?.json['boot_id'] == boot; try { - await api.postJson(path, { - 'configuration': jsonEncode({ - 'clear': true, - 'lease_id': value, - 'revision': revision, - }), + await api.previewCorrections({ + 'clear': true, + 'lease_id': value, + 'revision': revision, }, authorize: authorized); } catch (_) { // No mutation replay. The board's bounded lease is the recovery path. diff --git a/mobile/lib/pixel_corrections_editor.dart b/mobile/lib/pixel_corrections_editor.dart index 0e94cf46..e7288e41 100644 --- a/mobile/lib/pixel_corrections_editor.dart +++ b/mobile/lib/pixel_corrections_editor.dart @@ -8,9 +8,13 @@ class PixelCorrectionRuleIntent { required this.kind, required this.pixel, this.offset = 0, + this.revision, }); final String kind; final int pixel, offset; + + /// Revision of the saved raw-to-wire projection used to choose this slot. + final int? revision; Map toRule() => { 'kind': kind, 'pixel': pixel, diff --git a/mobile/lib/pixel_corrections_page.dart b/mobile/lib/pixel_corrections_page.dart index c266eb27..d9a6ca70 100644 --- a/mobile/lib/pixel_corrections_page.dart +++ b/mobile/lib/pixel_corrections_page.dart @@ -14,7 +14,7 @@ import 'workspace_widgets.dart'; typedef PixelCorrectionsPeer = ({int connection, Object? board, Object? boot}); -/// Peer-scoped editor: local previews never change the board or its raw scene. +/// Peer-scoped drafts: transient previews never persist or change the raw scene. class PixelCorrectionsPage extends StatefulWidget { const PixelCorrectionsPage({ super.key, @@ -25,6 +25,7 @@ class PixelCorrectionsPage extends StatefulWidget { this.expectedPeer, this.incomingRule, this.active = true, + this.livePreviewInitiallyEnabled, }); final PakarApi api; final ValueChanged busyChanged; @@ -35,6 +36,7 @@ class PixelCorrectionsPage extends StatefulWidget { final PixelCorrectionsPeer? expectedPeer; final PixelCorrectionRuleIntent? incomingRule; final bool active; + final bool? livePreviewInitiallyEnabled; @override State createState() => PixelCorrectionsPageState(); } @@ -54,6 +56,9 @@ class PixelCorrectionsPageState extends State final rulesScroll = ScrollController(); int nextRuleId = 0; bool enabled = true, busy = false, dirty = false; + // Default-on policy is not an active lease: opening/loading never writes. + // An explicit edit starts the bounded RAM preview; Save alone writes flash. + late bool previewOnEdit; String? error; StreamSubscription? updates; StreamSubscription? configurationUpdates; @@ -99,6 +104,10 @@ class PixelCorrectionsPageState extends State @override void initState() { super.initState(); + previewOnEdit = + widget.livePreviewInitiallyEnabled ?? + widget.workspace?.correctionLivePreview ?? + true; final observed = widget.api.freshStatus; final expected = widget.expectedPeer ?? @@ -225,7 +234,7 @@ class PixelCorrectionsPageState extends State saved != null && !busy && !_applyingIncomingRule && current; /// Append one explicit output-slot intent only after verified configuration. - /// This is a local draft, even if a previous editor preview was enabled. + /// Save remains explicit; the enabled preview policy may display this draft. Future _consumeIncomingRule() async { final intent = widget.incomingRule; if (intent == null || @@ -246,6 +255,14 @@ class PixelCorrectionsPageState extends State setState(() => error = 'Correction target changed. Reopen this editor.'); return; } + if (intent.revision != null && + (dirty || intent.revision != saved!['revision'])) { + setState( + () => error = + 'Correction mapping changed. Select the diagnostic pixel again.', + ); + return; + } if (rules.length >= maxRules) { setState( () => error = 'The board allows at most $maxRules correction rules.', @@ -262,9 +279,9 @@ class PixelCorrectionsPageState extends State return; } _applyingIncomingRule = true; + var appended = false; try { // stop() disables renewal synchronously; only our old lease may be cleared. - // Never let continuous preview silently publish the new menu draft. if (preview.enabled) await preview.stop(); if (!mounted || !widget.active || @@ -279,6 +296,7 @@ class PixelCorrectionsPageState extends State dirty = true; error = null; }); + appended = true; } finally { _applyingIncomingRule = false; if (mounted) { @@ -288,6 +306,14 @@ class PixelCorrectionsPageState extends State } } } + if (appended && + mounted && + previewOnEdit && + previewSupported && + invalidFields.isEmpty && + toggleSupported) { + await preview.start(); + } } CellMap? get addressMapping { @@ -365,8 +391,15 @@ class PixelCorrectionsPageState extends State change(); dirty = true; }); - if (invalidFields.isEmpty && toggleSupported) { - preview.edited(); + if (invalidFields.isEmpty && + toggleSupported && + previewOnEdit && + previewSupported) { + if (preview.enabled) { + preview.edited(); + } else { + unawaited(preview.start()); + } } else { unawaited(preview.stop()); } @@ -390,12 +423,34 @@ class PixelCorrectionsPageState extends State void importPixel(int pixel) { if (!editable) return; + final index = selectedRule == null ? -1 : rules.indexOf(selectedRule!); + final preceding = index < 0 ? rules : rules.take(index).toList(); + final List matches; + try { + matches = pixelCorrectionOutputSlots( + raw: pixel, + pixels: pixels, + sources: pixelCorrectionSources(pixels, enabled, preceding), + ); + } on FormatException catch (failure) { + setState(() => error = failure.message.toString()); + return; + } + if (matches.length != 1) { + setState( + () => error = matches.isEmpty + ? 'Source ${pixel + 1} is absent before this rule. Enter its output slot explicitly.' + : 'Source ${pixel + 1} reaches output slots ${matches.map((value) => value + 1).join(', ')}. Choose an output slot explicitly.', + ); + return; + } + final wire = matches.single; if (selectedRule == null || !rules.contains(selectedRule)) { - addRule(pixel); + addRule(wire); return; } edit(() { - selectedRule!['pixel'] = pixel; + selectedRule!['pixel'] = wire; invalidFields.remove('${idOf(selectedRule!)}-pixel'); final id = idOf(selectedRule!); importedDrafts[id] = (importedDrafts[id] ?? 0) + 1; @@ -446,9 +501,9 @@ class PixelCorrectionsPageState extends State }); widget.busyChanged(true); try { - if (widget.api.isUsb || widget.api.isBluetooth) { + if (widget.api.isBluetooth) { throw StateError( - 'Pixel correction configuration currently requires the board Wi-Fi connection.', + 'Pixel correction configuration requires Wi-Fi or USB for this connection.', ); } if (save) { @@ -1037,22 +1092,30 @@ class PixelCorrectionsPageState extends State 'Preview the draft on the LEDs. Save to board makes it persistent.', child: FilterChip( key: const ValueKey('correction-live-preview'), - showCheckmark: false, + showCheckmark: true, avatar: Icon( live ? Icons.visibility_rounded : Icons.visibility_outlined, size: 18, ), label: Text(preview.pending && !live ? 'Starting…' : 'Live preview'), - selected: preview.enabled, - onSelected: preview.enabled - ? (_) => unawaited(preview.stop()) + selected: previewOnEdit, + onSelected: previewOnEdit + ? (_) { + setState(() => previewOnEdit = false); + widget.workspace?.correctionLivePreview = false; + unawaited(preview.stop()); + } : editable && !preview.pending && widget.active && foreground && invalidFields.isEmpty && toggleSupported - ? (_) => unawaited(preview.start()) + ? (_) { + setState(() => previewOnEdit = true); + widget.workspace?.correctionLivePreview = true; + unawaited(preview.start()); + } : null, ), ); diff --git a/mobile/lib/pixel_diagnostic.dart b/mobile/lib/pixel_diagnostic.dart index b762ca69..54f7c0eb 100644 --- a/mobile/lib/pixel_diagnostic.dart +++ b/mobile/lib/pixel_diagnostic.dart @@ -119,6 +119,7 @@ class PixelDiagnosticObservation { /// Cursor/colour are user-owned workspace state, not a claim about board output. class PixelDiagnosticWorkspace { PixelDiagnosticWorkspace({Map saved = const {}}) { + _correctionLivePreview = saved['correction_live_preview'] != false; final index = saved['index'], rgb = saved['rgb']; _probe = PixelProbe( index: index is int && index >= 0 && index < diagnosticPixels ? index : 0, @@ -236,6 +237,14 @@ class PixelDiagnosticWorkspace { void dispose() => _listeners.clear(); String _position = '1'; double _vertical = 0; + bool _correctionLivePreview = true; + bool get correctionLivePreview => _correctionLivePreview; + set correctionLivePreview(bool value) { + if (value == _correctionLivePreview) return; + _correctionLivePreview = value; + notifyListeners(); + } + String get position => _position; set position(String value) { if (value.length > 8 || value == _position) return; @@ -266,6 +275,7 @@ class PixelDiagnosticWorkspace { 'rgb': probe.rgb, 'position': position, 'vertical': vertical, + 'correction_live_preview': correctionLivePreview, if (probe.extended) ...{ 'effect': probe.effect, 'overlay': probe.overlay, diff --git a/mobile/lib/signal_indicator.dart b/mobile/lib/signal_indicator.dart new file mode 100644 index 00000000..4ed0c5b6 --- /dev/null +++ b/mobile/lib/signal_indicator.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum SignalDisplay { icon, value, both } + +/// Client-local display preference. Never changes the board or connection. +/// Retains the imported workspace chrome's bounded 48px InkWell target. +class SignalIndicator extends StatefulWidget { + const SignalIndicator({super.key, required this.rssi, this.onConnection}); + final int rssi; + final VoidCallback? onConnection; + static const preferenceKey = 'status_signal_display'; + + @override + State createState() => _SignalIndicatorState(); +} + +class _SignalIndicatorState extends State { + SignalDisplay display = SignalDisplay.icon; + bool saving = false; + + @override + void initState() { + super.initState(); + unawaited(restore()); + } + + Future restore() async { + try { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(SignalIndicator.preferenceKey); + if (!mounted) return; + setState( + () => display = SignalDisplay.values.firstWhere( + (value) => value.name == saved, + orElse: () => SignalDisplay.icon, + ), + ); + } on Object { + // An unavailable local preference store must not affect live telemetry. + } + } + + Future choose(BuildContext anchor) async { + if (saving) return; + final box = anchor.findRenderObject() as RenderBox?; + final overlay = Overlay.of(anchor).context.findRenderObject() as RenderBox?; + if (box == null || overlay == null) return; + final rect = box.localToGlobal(Offset.zero, ancestor: overlay) & box.size; + final chosen = await showMenu( + context: context, + position: RelativeRect.fromRect(rect, Offset.zero & overlay.size), + semanticLabel: 'Signal display', + items: [ + for (final mode in SignalDisplay.values) + CheckedPopupMenuItem( + value: mode, + checked: display == mode, + child: Row( + children: [ + Icon(switch (mode) { + SignalDisplay.icon => Icons.wifi, + SignalDisplay.value => Icons.numbers, + SignalDisplay.both => Icons.view_week_outlined, + }, size: 20), + const SizedBox(width: 12), + Flexible( + child: Text( + switch (mode) { + SignalDisplay.icon => 'Icon only', + SignalDisplay.value => 'Value only', + SignalDisplay.both => 'Icon and value', + }, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ); + if (!mounted || chosen == null || chosen == display) return; + setState(() => saving = true); + try { + final prefs = await SharedPreferences.getInstance(); + if (!await prefs.setString(SignalIndicator.preferenceKey, chosen.name)) { + throw StateError('Preference write failed'); + } + if (mounted) setState(() => display = chosen); + } on Object { + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not save the signal display preference.'), + ), + ); + } + } finally { + if (mounted) setState(() => saving = false); + } + } + + @override + Widget build(BuildContext context) { + final rssi = widget.rssi; + final valid = rssi < 0 && rssi >= -127; + final level = !valid + ? 0 + : rssi >= -55 + ? 4 + : rssi >= -67 + ? 3 + : rssi >= -75 + ? 2 + : rssi >= -85 + ? 1 + : 0; + final quality = !valid + ? 'Unavailable' + : ['Very weak', 'Weak', 'Fair', 'Good', 'Excellent'][level]; + final label = 'Wi-Fi signal: $quality${valid ? ' ($rssi dBm)' : ''}'; + final icon = [ + Icons.signal_wifi_0_bar, + Icons.network_wifi_1_bar, + Icons.network_wifi_2_bar, + Icons.network_wifi_3_bar, + Icons.signal_wifi_4_bar, + ][level]; + final colors = Theme.of(context).colorScheme; + final color = !valid + ? colors.onSurfaceVariant + : level <= 1 + ? colors.tertiary + : colors.secondary; + return Builder( + builder: (anchor) => Tooltip( + message: '$label. Long-press to change display.', + triggerMode: TooltipTriggerMode.manual, + child: InkWell( + key: const Key('status-signal'), + borderRadius: BorderRadius.circular(8), + onTap: widget.onConnection, + onLongPress: saving ? null : () => unawaited(choose(anchor)), + child: Semantics( + label: label, + excludeSemantics: true, + child: SizedBox( + height: 48, + width: switch (display) { + SignalDisplay.icon => 48, + SignalDisplay.value => 88, + SignalDisplay.both => 116, + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (display != SignalDisplay.value) + Icon(icon, size: 20, color: color), + if (display == SignalDisplay.both) const SizedBox(width: 6), + if (display != SignalDisplay.icon) + Flexible( + child: Text( + valid ? '$rssi dBm' : '—', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: color), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/state_appearance_page.dart b/mobile/lib/state_appearance_page.dart index c06a26ec..ee909d1a 100644 --- a/mobile/lib/state_appearance_page.dart +++ b/mobile/lib/state_appearance_page.dart @@ -1,17 +1,29 @@ import 'dart:convert'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import 'models.dart'; +import 'command_readback.dart'; +import 'color_picker.dart'; /// Uses the same acknowledged command owner as table and key controls. /// Draft fields and temporary previews never stand in for saved observations. class StateAppearancePage extends StatefulWidget { - const StateAppearancePage({super.key, required this.status, required this.command, - required this.readStatus, required this.ids}); + const StateAppearancePage({ + super.key, + required this.status, + required this.command, + required this.readStatus, + required this.ids, + this.statusUpdates, + }); final DeviceStatus status; final Future Function(String, Map) command; final Future Function() readStatus; final String ids; + final Stream? statusUpdates; @override State createState() => _StateAppearancePageState(); } @@ -19,95 +31,348 @@ class StateAppearancePage extends StatefulWidget { class _StateAppearancePageState extends State { late DeviceStatus observed = widget.status; bool busy = false; + bool peerRetired = false; String? error; + final timingDrafts = {}; + StreamSubscription? subscription; final testColor = TextEditingController(text: '00FF00'); - Map get presentation => observed.json['state_presentation'] as Map? ?? const {}; - bool get samePeer => observed.json['board_id'] == widget.status.json['board_id'] && + Map get presentation => + observed.json['state_presentation'] as Map? ?? const {}; + bool get samePeer => + !peerRetired && + observed.json['board_id'] == widget.status.json['board_id'] && observed.json['boot_id'] == widget.status.json['boot_id']; @override - void dispose() {testColor.dispose();super.dispose();} + void initState() { + super.initState(); + subscription = widget.statusUpdates?.listen((next) { + if (!mounted || next.olderThan(observed)) return; + // Small telemetry frames need not contain the settings body. They may + // retire a peer, but must not erase its last complete appearance state. + final changedPeer = + next.json['board_id'] != observed.json['board_id'] || + next.json['boot_id'] != observed.json['boot_id']; + if (changedPeer || next.json['state_presentation'] is Map) { + setState(() { + if (changedPeer) peerRetired = true; + observed = next; + }); + } + }); + } + + @override + void dispose() { + subscription?.cancel(); + testColor.dispose(); + super.dispose(); + } + Future send(String path, Map values) async { - if(busy || !samePeer) return; - setState(() {busy=true;error=null;}); + if (busy || !samePeer) return; + setState(() { + busy = true; + error = null; + }); try { - final current = await widget.readStatus(); - if(current.json['board_id'] != widget.status.json['board_id'] || - current.json['boot_id'] != widget.status.json['boot_id']) { - throw const FormatException('Board changed; reopen appearance settings'); - } - if(!await widget.command(path, values)) { + if (!await widget.command(path, values)) { throw const FormatException('Device did not confirm this change'); } final next = await widget.readStatus(); - if(mounted) setState(() => observed=next); - } on Object catch(e) { - if(mounted) setState(() => error='$e'); - } finally {if(mounted) setState(() => busy=false);} + if (next.json['board_id'] != widget.status.json['board_id'] || + next.json['boot_id'] != widget.status.json['boot_id']) { + throw const FormatException( + 'Board changed; reopen appearance settings', + ); + } + if (path == '/api/led/presentation' && + !commandReadbackMatches(next, path, values)) { + throw const FormatException( + 'Settings changed before readback; review the current values', + ); + } + if (mounted) { + setState(() { + if (!peerRetired && !next.olderThan(observed)) observed = next; + if (path == '/api/led/presentation') { + final patch = jsonDecode(values['settings']!) as Map; + for (final field in patch.keys) { + if (int.tryParse(timingDrafts[field] ?? '') == patch[field]) { + timingDrafts.remove(field); + } + } + } + }); + } + } on Object catch (e) { + if (mounted) setState(() => error = '$e'); + } finally { + if (mounted) setState(() => busy = false); + } } - Future patch(Map value) => send('/api/led/presentation', {'settings':jsonEncode(value)}); + + Future patch(Map value) => + send('/api/led/presentation', {'settings': jsonEncode(value)}); Future chooseColor(String state, int rgb) async { - final input=TextEditingController(text:rgb.toRadixString(16).padLeft(6,'0').toUpperCase()); - final value=await showDialog(context:context,builder:(context)=>AlertDialog( - title:Text('$state color'), - content:TextField(controller:input,autofocus:true,maxLength:6, - inputFormatters:[FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]'))], - decoration:const InputDecoration(prefixText:'#',labelText:'RGB hex')), - actions:[TextButton(onPressed:()=>Navigator.pop(context),child:const Text('Cancel')), - FilledButton(onPressed:(){if(input.text.length==6)Navigator.pop(context,int.parse(input.text,radix:16));},child:const Text('Apply'))])); - input.dispose(); - if(value!=null && mounted)await patch({'states':{state:{'rgb':value}}}); + final value = await showLedColorPicker( + context, + initial: rgb, + title: '$state color', + ); + if (value != null && mounted) { + await patch({ + 'states': { + state: {'rgb': value}, + }, + }); + } } + @override Widget build(BuildContext context) { - final states=presentation['states'] as Map? ?? const {}; - return Scaffold(appBar:AppBar(title:const Text('State colors & effects')), - body:ListView(padding:const EdgeInsets.all(16),children:[ - SwitchListTile(title:const Text('Extended effects'),value:presentation['enabled']==true, - onChanged:busy||!samePeer?null:(v)=>patch({'enabled':v})), - for(final state in ['idle','won','lost','off']) Builder(builder:(context){ - final style=states[state] as Map? ?? const {}; - final rgb=style['rgb'];final motion=style['motion']; - return Card(child:Padding(padding:const EdgeInsets.all(12),child:Wrap( - spacing:16,runSpacing:8,crossAxisAlignment:WrapCrossAlignment.center,children:[ - SizedBox(width:60,child:Text(state.toUpperCase(),style:Theme.of(context).textTheme.labelLarge)), - OutlinedButton.icon(onPressed:busy||!samePeer||state=='off'||rgb is! int?null:()=>chooseColor(state,rgb), - icon:Icon(Icons.circle,color:rgb is int?Color(0xff000000|rgb):null), - label:Text(rgb is int?'#${rgb.toRadixString(16).padLeft(6,'0').toUpperCase()}':'Unknown')), - if(state!='off' && (observed.peer.tableSelection || state!='idle') && motion is int && motion>=0 && motion<4) - DropdownButton(value:motion,items:[ - for(final entry in ['Solid','Pulse','Circular chase','Chase'].asMap().entries) - DropdownMenuItem(value:entry.key,child:Text(entry.value))], - onChanged:busy||!samePeer?null:(v){if(v!=null)patch({'states':{state:{'motion':v}}});}), - ]))); - }), - for(final field in const {'fade_ms':'Transition (ms)','pulse_ms':'Pulse period (ms)', - 'ring_ms':'Circular period (ms)','chase_ms':'Chase period (ms)'}.entries) - Padding(padding:const EdgeInsets.symmetric(vertical:6),child:TextFormField( - key:ValueKey('${field.key}:${presentation[field.key]}'),initialValue:'${presentation[field.key] ?? ''}', - enabled:!busy&&samePeer,keyboardType:TextInputType.number, - inputFormatters:[FilteringTextInputFormatter.digitsOnly], - decoration:InputDecoration(labelText:field.value), - onFieldSubmitted:(text){final n=int.tryParse(text);if(n!=null)patch({field.key:n});})), - SwitchListTile(title:const Text('Chase affects adjacent pixels'),value:presentation['neighbors']==true, - onChanged:busy||!samePeer?null:(v)=>patch({'neighbors':v})), - SwitchListTile(title:const Text('Bright chasing dot'),value:presentation['invert']==true, - onChanged:busy||!samePeer?null:(v)=>patch({'invert':v})), - if(observed.peer.tableSelection) SwitchListTile(title:const Text('Diagonal lost-state ripple'), - value:presentation['diagonal']==true,onChanged:busy||!samePeer?null:(v)=>patch({'diagonal':v})), - const Divider(), - Text('Temporary color test',style:Theme.of(context).textTheme.titleMedium), - TextField(controller:testColor,maxLength:6,inputFormatters:[FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]'))], - decoration:const InputDecoration(prefixText:'#',labelText:'RGB hex')), - Wrap(spacing:12,children:[ - FilledButton.icon(icon:const Icon(Icons.visibility_outlined),label:const Text('Preview · 5 seconds'), - onPressed:busy||!samePeer||widget.ids.isEmpty?null:(){ - final color=int.tryParse(testColor.text,radix:16); - if(color!=null&&testColor.text.length==6)send('/api/led/preview',{'ids':widget.ids,'rgb':'$color','duration_ms':'5000'}); - }), - OutlinedButton.icon(icon:const Icon(Icons.stop),label:const Text('Stop preview'), - onPressed:busy||!samePeer?null:()=>send('/api/led/preview',{'cancel':'true'})), - ]), - SizedBox(height:48,child:busy?const LinearProgressIndicator():Text(error??'',style:TextStyle(color:Theme.of(context).colorScheme.error))), - ])); + final states = presentation['states'] as Map? ?? const {}; + return Scaffold( + appBar: AppBar(title: const Text('State colors & effects')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (observed.peer.tableSelection && + const [0, 90, 180, 270].contains(presentation['fire_rotation'])) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + key: ValueKey('fire-direction:${presentation['fire_rotation']}'), + initialValue: presentation['fire_rotation'] as int, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Fire direction', + prefixIcon: Icon(Icons.local_fire_department_outlined), + ), + items: const [ + DropdownMenuItem(value: 0, child: Text('0° · Up')), + DropdownMenuItem(value: 90, child: Text('90° · Right')), + DropdownMenuItem(value: 180, child: Text('180° · Down')), + DropdownMenuItem(value: 270, child: Text('270° · Left')), + ], + onChanged: busy || !samePeer ? null : (value) { + if (value != null && value != presentation['fire_rotation']) { + patch({'fire_rotation': value}); + } + }, + ), + ), + SwitchListTile( + title: const Text('Extended effects'), + value: presentation['enabled'] == true, + onChanged: busy || !samePeer ? null : (v) => patch({'enabled': v}), + ), + for (final state in ['idle', 'won', 'lost', 'off']) + Builder( + builder: (context) { + final style = states[state] as Map? ?? const {}; + final rgb = style['rgb']; + final motion = style['motion']; + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 16, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 60, + child: Text( + state.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + OutlinedButton.icon( + onPressed: + busy || !samePeer || state == 'off' || rgb is! int + ? null + : () => chooseColor(state, rgb), + icon: Icon( + Icons.circle, + color: rgb is int ? Color(0xff000000 | rgb) : null, + ), + label: Text( + rgb is int + ? '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}' + : 'Unknown', + ), + ), + if (state != 'off' && + (observed.peer.tableSelection || state != 'idle') && + motion is int && + motion >= 0 && + motion < 4) + DropdownButton( + value: motion, + items: [ + for (final entry in [ + 'Solid', + 'Pulse', + 'Circular chase', + 'Chase', + ].asMap().entries) + DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ), + ], + onChanged: busy || !samePeer + ? null + : (v) { + if (v != null) { + patch({ + 'states': { + state: {'motion': v}, + }, + }); + } + }, + ), + ], + ), + ), + ); + }, + ), + for (final field in const { + 'fade_ms': 'Transition (ms)', + 'pulse_ms': 'Pulse period (ms)', + 'ring_ms': 'Circular period (ms)', + 'chase_ms': 'Chase period (ms)', + }.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: TextFormField( + key: ValueKey('${field.key}:${presentation[field.key]}'), + initialValue: + timingDrafts[field.key] ?? + '${presentation[field.key] ?? ''}', + enabled: !busy && samePeer, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (value) => + setState(() => timingDrafts[field.key] = value), + decoration: InputDecoration( + labelText: field.value, + suffixIcon: IconButton( + tooltip: 'Apply ${field.value}', + icon: const Icon(Icons.check), + onPressed: + busy || + !samePeer || + !timingDrafts.containsKey(field.key) + ? null + : () { + final n = int.tryParse(timingDrafts[field.key]!); + if (n == null || n < 0 || n > 65535) { + setState( + () => error = + 'Enter a duration from 0 to 65535 ms', + ); + return; + } + patch({field.key: n}); + }, + ), + ), + onFieldSubmitted: (text) { + final n = int.tryParse(text); + if (n != null) patch({field.key: n}); + }, + ), + ), + SwitchListTile( + title: const Text('Chase affects adjacent pixels'), + value: presentation['neighbors'] == true, + onChanged: busy || !samePeer + ? null + : (v) => patch({'neighbors': v}), + ), + SwitchListTile( + title: const Text('Bright chasing dot'), + value: presentation['invert'] == true, + onChanged: busy || !samePeer ? null : (v) => patch({'invert': v}), + ), + if (observed.peer.tableSelection) + SwitchListTile( + title: const Text('Diagonal lost-state ripple'), + value: presentation['diagonal'] == true, + onChanged: busy || !samePeer + ? null + : (v) => patch({'diagonal': v}), + ), + const Divider(), + Text( + 'Temporary color test', + style: Theme.of(context).textTheme.titleMedium, + ), + OutlinedButton.icon( + icon: Icon( + Icons.palette_outlined, + color: Color( + 0xff000000 | (int.tryParse(testColor.text, radix: 16) ?? 0), + ), + ), + label: Text(rgbName(int.tryParse(testColor.text, radix: 16) ?? 0)), + onPressed: busy + ? null + : () async { + final rgb = await showLedColorPicker( + context, + initial: + int.tryParse(testColor.text, radix: 16) ?? 0x00ff00, + ); + if (rgb != null && mounted) { + setState(() => testColor.text = rgbHex(rgb)); + } + }, + ), + Wrap( + spacing: 12, + children: [ + FilledButton.icon( + icon: const Icon(Icons.visibility_outlined), + label: const Text('Preview · 5 seconds'), + onPressed: busy || !samePeer || widget.ids.isEmpty + ? null + : () { + final color = int.tryParse(testColor.text, radix: 16); + if (color != null && testColor.text.length == 6) { + send('/api/led/preview', { + 'ids': widget.ids, + 'rgb': '$color', + 'duration_ms': '5000', + }); + } + }, + ), + OutlinedButton.icon( + icon: const Icon(Icons.stop), + label: const Text('Stop preview'), + onPressed: busy || !samePeer + ? null + : () => send('/api/led/preview', {'cancel': 'true'}), + ), + ], + ), + SizedBox( + height: 48, + child: busy + ? const LinearProgressIndicator() + : Text( + error ?? '', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ); } } diff --git a/mobile/lib/table_viewport.dart b/mobile/lib/table_viewport.dart index 9d66e48a..bde73a66 100644 --- a/mobile/lib/table_viewport.dart +++ b/mobile/lib/table_viewport.dart @@ -38,7 +38,63 @@ class TableViewport extends StatefulWidget { State createState() => _TableViewportState(); } -class _TableViewportState extends State { +// Claim the two-finger gesture before the enclosing page can win a vertical +// drag. Single-finger taps/holds and scrolling keep their normal arena owner. +class _TableScaleRecognizer extends ScaleGestureRecognizer { + _TableScaleRecognizer({super.debugOwner}); + final Set pointers = {}; + @override + void addAllowedPointer(PointerDownEvent event) { + pointers.add(event.pointer); + super.addAllowedPointer(event); + if (pointers.length == 2) resolve(GestureDisposition.accepted); + } + + @override + void handleEvent(PointerEvent event) { + super.handleEvent(event); + if (event is PointerUpEvent || event is PointerCancelEvent) { + pointers.remove(event.pointer); + } + } + + @override + void rejectGesture(int pointer) { + // The SDK stops routing this pointer after rejection, so an eventual Up + // cannot clear our set. Never let a prior page drag count as finger two. + pointers.remove(pointer); + super.rejectGesture(pointer); + } +} + +class _TableViewportState extends State + with SingleTickerProviderStateMixin { + late final AnimationController snap = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 140), + ); + Offset snapFrom = Offset.zero, snapTo = Offset.zero; + @override + void initState() { + super.initState(); + snap.addListener(() { + widget.controller.change( + widget.controller.zoom, + Offset.lerp( + snapFrom, + snapTo, + Curves.easeOutCubic.transform(snap.value), + )!, + ); + }); + } + + @override + void dispose() { + snap.dispose(); + super.dispose(); + } + double previousScale = 1; Offset previous = Offset.zero; bool gestureActive = false; @@ -71,11 +127,11 @@ class _TableViewportState extends State { final limit = Offset( math.max( 0, - (widget.contentSize.width * fit * zoom + size.width) / 2 - 24, + (widget.contentSize.width * fit * zoom - size.width) / 2, ), math.max( 0, - (widget.contentSize.height * fit * zoom + size.height) / 2 - 24, + (widget.contentSize.height * fit * zoom - size.height) / 2, ), ); return Offset( @@ -85,7 +141,22 @@ class _TableViewportState extends State { } final visiblePan = constrain(view.pan, view.zoom); + void settle() { + cancelGesture(); + final pitch = widget.contentSize.width / 20 * fit * view.zoom; + snapFrom = constrain(view.pan, view.zoom); + snapTo = constrain( + Offset( + (snapFrom.dx / pitch).round() * pitch, + (snapFrom.dy / pitch).round() * pitch, + ), + view.zoom, + ); + if (snapFrom != snapTo) snap.forward(from: 0); + } + void zoomAt(double next, Offset from, Offset to) { + snap.stop(); next = next.clamp(.5, 8); // Pealayer timelineZoomAtPointer, extended to both axes and centered fit. final scene = @@ -102,7 +173,10 @@ class _TableViewportState extends State { Row( children: [ TextButton.icon( - onPressed: view.fit, + onPressed: () { + snap.stop(); + view.fit(); + }, icon: const Icon(Icons.fit_screen), label: const Text('Fit'), ), @@ -144,6 +218,7 @@ class _TableViewportState extends State { GestureBinding.instance.pointerSignalResolver.register( event, (_) { + snap.stop(); if (zooming) { zoomAt( view.zoom * @@ -175,10 +250,10 @@ class _TableViewportState extends State { key: const Key('table-viewport-gestures'), behavior: HitTestBehavior.opaque, gestures: { - ScaleGestureRecognizer: + _TableScaleRecognizer: GestureRecognizerFactoryWithHandlers< - ScaleGestureRecognizer - >(() => ScaleGestureRecognizer(debugOwner: this), ( + _TableScaleRecognizer + >(() => _TableScaleRecognizer(debugOwner: this), ( recognizer, ) { recognizer @@ -189,6 +264,7 @@ class _TableViewportState extends State { ? const DeviceGestureSettings(touchSlop: 4) : MediaQuery.maybeGestureSettingsOf(context) ..onStart = (details) { + snap.stop(); gestureActive = true; previousScale = 1; previous = details.localFocalPoint; @@ -207,7 +283,7 @@ class _TableViewportState extends State { previousScale = details.scale; previous = details.localFocalPoint; } - ..onEnd = (_) => cancelGesture(); + ..onEnd = (_) => settle(); }), }, child: Stack( diff --git a/mobile/lib/table_workspace.dart b/mobile/lib/table_workspace.dart index 64581c58..446ef268 100644 --- a/mobile/lib/table_workspace.dart +++ b/mobile/lib/table_workspace.dart @@ -61,6 +61,12 @@ class TableWorkspace extends ChangeNotifier { ? effect as String : 'immediate'; final keyPositions = saved['key_positions']; + final selectedKeys = saved['key_selection']; + if (selectedKeys is List) { + _selectedKeys.addAll( + selectedKeys.whereType().where((key) => key >= 1 && key <= 9), + ); + } if (keyPositions is Map) { for (var key = 1; key <= 9; key++) { final raw = keyPositions['$key']; @@ -183,6 +189,20 @@ class TableWorkspace extends ChangeNotifier { } final Map _keyPositions = {}; + final Set _selectedKeys = {}; + Set get selectedKeys => Set.unmodifiable(_selectedKeys); + void selectKeys(Set keys) { + final next = keys.where((key) => key >= 1 && key <= 9).toSet(); + if (_selectedKeys.length == next.length && + _selectedKeys.containsAll(next)) { + return; + } + _selectedKeys + ..clear() + ..addAll(next); + notifyListeners(); + } + Offset keyPosition(int key) => _keyPositions[key] ?? defaultKeyPosition(key); void moveKey(int key, Offset position) { if (key < 1 || key > 9 || !position.dx.isFinite || !position.dy.isFinite) { @@ -219,6 +239,7 @@ class TableWorkspace extends ChangeNotifier { for (var key = 1; key <= 9; key++) '$key': [keyPosition(key).dx, keyPosition(key).dy], }, + 'key_selection': _selectedKeys.toList()..sort(), 'vertical': vertical, 'horizontal': horizontal, 'viewport': { diff --git a/mobile/lib/usb_transport.dart b/mobile/lib/usb_transport.dart index c92cc99d..2389f374 100644 --- a/mobile/lib/usb_transport.dart +++ b/mobile/lib/usb_transport.dart @@ -821,6 +821,60 @@ class PakarUsbTransport { ); }); + Future> networkApi( + String method, + String path, + Map body, { + required bool Function() authorize, + }) => _exclusive(() async { + if (!ready || !authorize() || !latest!.peer.has('network-tools')) { + throw const FormatException( + 'Network tools are not advertised by this board', + ); + } + const reads = { + '/api/network/tools', + '/api/time', + '/api/wifi/scan', + '/api/network', + }; + const writes = {'/api/network/tools', '/api/network/probe', '/api/network'}; + if (!(method == 'GET' + ? reads + : method == 'POST' + ? writes + : const {}) + .contains(path)) { + throw const FormatException('Unsupported USB network operation'); + } + return _apiJson(method, path, body, authorize); + }); + + /// The common boot-fenced API transaction also carries correction settings. + /// Preview stays RAM-only; only the explicit non-preview POST persists. + Future> pixelCorrectionsApi({ + Map? configuration, + bool preview = false, + required bool Function() authorize, + }) => _exclusive(() async { + if (!ready || + !authorize() || + !latest!.peer.has('usb-api') || + !latest!.peer.has('pixel-corrections') || + (preview && !latest!.peer.has('pixel-corrections-preview')) || + (preview && configuration == null)) { + throw const FormatException( + 'USB pixel corrections are unavailable; nothing was sent', + ); + } + return _apiJson( + configuration == null ? 'GET' : 'POST', + preview ? '/api/pixels/corrections/preview' : '/api/pixels/corrections', + configuration ?? const {}, + authorize, + ); + }); + Future _advancedDiagnostic( PixelProbe probe, bool off, @@ -935,6 +989,24 @@ class PakarUsbTransport { throw const FormatException('USB board not ready; nothing sent'); } final before = latest!, features = before.peer; + if (path.startsWith('/api/minigame/')) { + if (!features.allows(path, values) || !features.has('usb-api')) { + throw const FormatException('USB minigame command unavailable'); + } + final packet = commandPacket(before, path, values); + final reply = await _apiJson( + 'POST', + '/api/command', + Map.from(jsonDecode(packet) as Map), + authorize, + ); + final actual = verifyCommandReply(reply, packet, path, values); + if (!authorize()) { + throw const FormatException('USB minigame owner retired'); + } + latest = actual; + return actual; + } const gamePaths = { '/api/game/settings', '/api/game/reset', diff --git a/mobile/lib/workspace_activity_strip.dart b/mobile/lib/workspace_activity_strip.dart index 8f9c58ba..ffdfb89f 100644 --- a/mobile/lib/workspace_activity_strip.dart +++ b/mobile/lib/workspace_activity_strip.dart @@ -137,7 +137,7 @@ class _WorkspaceActivityStripState extends State { Widget scrollingStrip(BuildContext context, double width) { final colors = Theme.of(context).colorScheme; // Keep one complete touch target within the viewport, even with enlarged - // RTL labels. The strip still scrolls; labels wrap rather than disappear. + // RTL labels. The strip scrolls; long labels keep a stable single line. final labelWidth = (width - MediaQuery.paddingOf(context).horizontal - 75) .clamp(24.0, double.infinity); return Material( @@ -195,6 +195,8 @@ class _WorkspaceActivityStripState extends State { ), child: Text( item.$2, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( color: item.$1 == widget.selected ? colors.primary diff --git a/mobile/test/appearance_live_test.dart b/mobile/test/appearance_live_test.dart new file mode 100644 index 00000000..7ecc77b1 --- /dev/null +++ b/mobile/test/appearance_live_test.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/models.dart'; +import 'package:pakarcontroller/state_appearance_page.dart'; +import 'package:pakarcontroller/color_picker.dart'; +import 'peer_fixture.dart'; + +DeviceStatus appearance({int motion = 0, String boot = 'boot'}) => + DeviceStatus({ + 'board_id': 'board', + 'boot_id': boot, + 'state_presentation': { + 'enabled': true, + 'states': { + 'won': {'rgb': 0x00ff00, 'motion': motion}, + }, + 'fade_ms': 350, + 'pulse_ms': 1200, + 'ring_ms': 800, + 'chase_ms': 1200, + }, + }); +void main() { + testWidgets('Fire direction follows saved readback without restarting on reads', (tester) async { + final updates = StreamController.broadcast(); + final base = peerFixture(features: ['table', 'table-selection', 'leds', 'state-presentation']).json; + DeviceStatus snapshot(int angle, {String boot = 'boot'}) => DeviceStatus({ + ...base, 'board_id': 'table-A', 'boot_id': boot, + 'state_presentation': {...appearance().json['state_presentation'] as Map, 'fire_rotation': angle}, + }); + var actual = snapshot(0), sends = 0; + await tester.pumpWidget(MaterialApp(home: StateAppearancePage( + status: actual, statusUpdates: updates.stream, ids: '1', readStatus: () async => actual, + command: (path, values) async { + expect(path, '/api/led/presentation'); + expect(values['settings'], '{"fire_rotation":180}'); + sends++; actual = snapshot(180); return true; + }, + ))); + final selector = find.byType(DropdownButtonFormField); + expect(selector, findsOneWidget); expect(sends, 0); + tester.widget>(selector).onChanged!(180); + await tester.pumpAndSettle(); + expect(find.text('180° · Down'), findsOneWidget); expect(sends, 1); + updates.add(snapshot(90)); await tester.pumpAndSettle(); + expect(find.text('90° · Right'), findsOneWidget); expect(sends, 1); + updates.add(snapshot(90, boot: 'replacement')); await tester.pumpAndSettle(); + expect(tester.widget>(selector).onChanged, isNull); + await tester.pumpWidget(const SizedBox()); await updates.close(); + }); + testWidgets( + 'Open effects page follows peer events and retires replaced boot', + (tester) async { + final updates = StreamController.broadcast(); + var actual = appearance(), sends = 0; + await tester.pumpWidget( + MaterialApp( + home: StateAppearancePage( + status: actual, + statusUpdates: updates.stream, + ids: '1', + readStatus: () async => actual, + command: (path, values) async { + sends++; + actual = appearance(motion: 2); + return true; + }, + ), + ), + ); + expect( + tester + .widget>(find.byType(DropdownButton)) + .value, + 0, + ); + updates.add(actual = appearance(motion: 1)); + await tester.pump(); + expect( + tester + .widget>(find.byType(DropdownButton)) + .value, + 1, + ); + final dropdown = tester.widget>( + find.byType(DropdownButton), + ); + dropdown.onChanged!(2); + await tester.pumpAndSettle(); + expect(sends, 1); + expect( + tester + .widget>(find.byType(DropdownButton)) + .value, + 2, + ); + updates.add(appearance(boot: 'replacement')); + await tester.pumpAndSettle(); + expect( + tester + .widget>(find.byType(DropdownButton)) + .onChanged, + isNull, + ); + await tester.pumpWidget(const SizedBox()); + await updates.close(); + }, + ); + testWidgets( + 'Color picker offers named swatches custom channels and explicit apply', + (tester) async { + int? selected; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => + selected = await showLedColorPicker(context, initial: 0), + child: const Text('Pick'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Pick')); + await tester.pumpAndSettle(); + expect(ledColorPalette.length, 16); + await tester.tap(find.byTooltip('Amber · #FFBF00')); + await tester.pump(); + expect(find.text('Amber'), findsOneWidget); + expect(find.byType(Slider), findsNWidgets(3)); + expect(selected, isNull); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(selected, 0xffbf00); + }, + ); +} diff --git a/mobile/test/avr_command_wire_test.dart b/mobile/test/avr_command_wire_test.dart new file mode 100644 index 00000000..5b131016 --- /dev/null +++ b/mobile/test/avr_command_wire_test.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/command_readback.dart'; +import 'package:pakarcontroller/command_wire.dart'; +import 'package:pakarcontroller/command_failure.dart'; +import 'package:pakarcontroller/models.dart'; + +import 'peer_fixture.dart'; + +void main() { + for (final command in [ + ('/api/audio', {'cue': 'welcome'}, 'audio'), + ('/api/buzzer', {'frequency': '880', 'duration_ms': '70'}, 'buzzer'), + ('/api/outputs', {'mask': '3'}, 'outputs'), + ]) { + test('AVR ${command.$3} sends typed values and requires actual ACK', () { + final status = DeviceStatus({ + ...peerFixture( + features: ['buzzer', 'outputs', 'command-ack', 'avr-command-ack'], + ).json, + 'board_id': 'avr-fixture', + 'boot_id': 'a' * 32, + 'command_clock_ms': 1000, + 'programming_latch': false, + 'active_outputs': 3, + 'buzzer': {'active': false}, + }); + final packet = commandPacket(status, command.$1, command.$2); + final request = jsonDecode(packet) as Map; + expect(request['command'], command.$3); + if (command.$3 == 'buzzer') { + expect(request['frequency'], 880); + expect(request['duration_ms'], 70); + } + if (command.$3 == 'outputs') expect(request['mask'], 3); + final reply = { + 'type': 'command-result', + 'request_id': request['request_id'], + 'board_id': 'avr-fixture', + 'boot_id': 'a' * 32, + 'accepted': true, + 'state': 'acknowledged', + 'physical_verified': false, + 'status': status.json, + }; + expect( + () => verifyCommandReply(reply, packet, command.$1, command.$2), + throwsFormatException, + ); + final actual = verifyCommandReply( + {...reply, 'avr_acknowledged': true}, + packet, + command.$1, + command.$2, + ); + expect( + commandReadbackMatches(actual, command.$1, command.$2), + true, + reason: 'a completed short tone does not need to still be playing', + ); + }); + } + test('correction refusal explains known reason and never echoes arbitrary server text', () { + expect( + commandFailureMessage(error: 'corrections_preview_owned'), + contains('Another editor'), + ); + expect( + commandFailureMessage(error: 'secret raw server text'), + isNot(contains('secret')), + ); + expect( + commandFailureMessage(error: 'avr_outcome_unknown'), + contains('did not acknowledge'), + ); + }); +} diff --git a/mobile/test/command_fallback_transport_test.dart b/mobile/test/command_fallback_transport_test.dart index 906d789b..61854d8f 100644 --- a/mobile/test/command_fallback_transport_test.dart +++ b/mobile/test/command_fallback_transport_test.dart @@ -6,8 +6,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pakarcontroller/api.dart'; import 'package:pakarcontroller/models.dart'; import 'package:pakarcontroller/pixel_diagnostic.dart'; +import 'package:pakarcontroller/minigame.dart'; import 'peer_fixture.dart'; +import 'minigame_test.dart' show miniStatus; Map status({PixelProbe? probe}) => { ...peerFixture( @@ -38,6 +40,8 @@ Map status({PixelProbe? probe}) => { }; class CommandServer { + CommandServer({this.snapshot}); + final Map Function()? snapshot; late HttpServer server; final sockets = []; final timers = []; @@ -52,11 +56,11 @@ class CommandServer { if (request.uri.path == '/ws') { final socket = await WebSocketTransformer.upgrade(request); sockets.add(socket); - socket.add(jsonEncode(status())); + socket.add(jsonEncode(snapshot?.call() ?? status())); socket.listen((message) { final q = jsonDecode(message as String) as Map; if (q['type'] != 'command') { - socket.add(jsonEncode(status())); + socket.add(jsonEncode(snapshot?.call() ?? status())); return; } packets.add(q); @@ -75,11 +79,11 @@ class CommandServer { jsonEncode({ 'type': 'command-result', 'request_id': q['request_id'], - 'board_id': 'fixture', - 'boot_id': 'a' * 32, + 'board_id': q['board_id'], + 'boot_id': q['boot_id'], 'accepted': true, if (q['command'] == 'cells') 'count': 2, - 'status': status(probe: probe), + 'status': snapshot?.call() ?? status(probe: probe), }), ), ), @@ -92,7 +96,7 @@ class CommandServer { writes++; } request.response.headers.contentType = ContentType.json; - request.response.write(jsonEncode(status())); + request.response.write(jsonEncode(snapshot?.call() ?? status())); await request.response.close(); } }); @@ -125,6 +129,30 @@ class CommandServer { } void main() { + test('Game box taps prefer WS with typed values and no HTTP write or preflight read', () async { + final base = miniStatus(phase: 'input').json; + final observed = {...base, 'board_id': 'fixture', 'boot_id': 'a' * 32}; + final server = CommandServer(snapshot: () => observed); + await server.start(); + final api = server.api(); + final live = await server.live(api); + try { + final reads = server.reads; + final actual = await api.fastCommand(minigameInput, { + 'key': '2', + 'action': 'tap', + }, authorize: () => true); + expect(actual?.peer.minigame?.phase, 'input'); + expect(server.packets.single['command'], 'minigame-input'); + expect(server.packets.single['key'], 2); + expect(server.writes, 0); + expect(server.reads, reads); + } finally { + await api.close(); + await live.cancel(); + await server.close(); + } + }); test('HTTP command fallback defaults enabled', () async { final api = PakarApi(); expect(api.commandHttpFallback, true); diff --git a/mobile/test/host_wifi_test.dart b/mobile/test/host_wifi_test.dart new file mode 100644 index 00000000..32da4d3e --- /dev/null +++ b/mobile/test/host_wifi_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pakarcontroller/host_wifi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('network hints use reported SSIDs, never hostname inference', () { + expect( + HostWifi.knownNetworks({ + 'hostname': 'not-an-ssid', + 'station_ssid': 'router', + }), + ['router'], + ); + expect(HostWifi.knownNetworks({'ap_ssid': 'a', 'station_ssid': 'a'}), [ + 'a', + ]); + expect(HostWifi.knownNetworks(null), isEmpty); + }); + test('SSID byte bounds and credentials remain separate from API login', () { + expect(HostWifi.validate('ledcontroller', '', true), isNull); + expect(HostWifi.validate('ledcontroller', '12345678', false), isNull); + expect(HostWifi.validate('é' * 17, '', true), isNotNull); + expect(HostWifi.validate('a\nb', '', true), isNotNull); + expect(HostWifi.validate('board', '', false), isNotNull); + expect(HostWifi.validate('board', 'x' * 64, false), isNotNull); + }); + test( + 'invalid input never invokes native networking; open omits password', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(HostWifi.channel, (call) async { + calls.add(call); + return 'cancelled'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(HostWifi.channel, null), + ); + await expectLater( + HostWifi.request('join', '', '', true), + throwsFormatException, + ); + expect(calls, isEmpty); + expect( + await HostWifi.request('join', 'board', 'unused secret', true), + 'cancelled', + ); + expect(calls.single.arguments['password'], ''); + }, + ); + testWidgets( + 'phone dialog remains usable with larger text and scrollable controls', + (tester) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(360, 740); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(1.3)), + child: Builder( + builder: (context) => TextButton( + onPressed: () => + showHostWifi(context, knownNetworks: ['a' * 32]), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Release app-only Wi-Fi')); + expect(find.text('Close').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.text('This device’s Wi-Fi'), findsNothing); + }, + ); + testWidgets( + 'opening shows explicit actions without requesting network; saves no password', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(HostWifi.channel, (call) async { + calls.add(call); + return 'saved'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(HostWifi.channel, null), + ); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => showHostWifi( + context, + scope: 'board-1', + knownNetworks: ['known-network'], + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(calls, isEmpty); + await tester.tap(find.text('known-network')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, '12345678'); + await tester.ensureVisible(find.text('Save in Android')); + await tester.tap(find.text('Save in Android')); + await tester.pumpAndSettle(); + expect(calls.single.method, 'save'); + expect(find.textContaining('Network saved in Android.'), findsOneWidget); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('pakar.host-wifi.board-1'), 'known-network'); + expect(prefs.getKeys(), {'pakar.host-wifi.board-1'}); + }, + ); +} diff --git a/mobile/test/key_box_designer_test.dart b/mobile/test/key_box_designer_test.dart new file mode 100644 index 00000000..3eca4576 --- /dev/null +++ b/mobile/test/key_box_designer_test.dart @@ -0,0 +1,238 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/key_box_designer.dart'; +import 'package:pakarcontroller/app_theme.dart'; +import 'package:pakarcontroller/minigame_panel.dart'; +import 'package:pakarcontroller/models.dart'; +import 'package:pakarcontroller/table_workspace.dart'; + +import 'minigame_test.dart' show miniStatus; + +void main() { + testWidgets( + 'Arrange and group drag preserve selection and never trigger IO', + (tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final workspace = TableWorkspace(); + var commands = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: KeyBoxDesigner( + status: miniStatus(), + workspace: workspace, + enabled: true, + onTap: (_) => commands++, + openSettings: () {}, + ), + ), + ), + ), + ); + expect(find.text('Button'), findsNWidgets(9)); + expect(find.text('Indicator'), findsNWidgets(9)); + await tester.tap(find.byKey(const ValueKey('key-designer-arrange'))); + await tester.pumpAndSettle(); + workspace.selectKeys({1, 2}); + await tester.pump(); + final a = workspace.keyPosition(1), b = workspace.keyPosition(2); + await tester.drag( + find.byKey(const ValueKey('game-key-drag-1')), + const Offset(90, 42), + ); + await tester.pumpAndSettle(); + final movedA = workspace.keyPosition(1), + movedB = workspace.keyPosition(2); + expect(movedA, isNot(a)); + expect((movedB - movedA).dx, closeTo((b - a).dx, .0001)); + expect((movedB - movedA).dy, closeTo((b - a).dy, .0001)); + expect(commands, 0); + final restored = TableWorkspace(saved: workspace.snapshot()); + expect(restored.selectedKeys, {1, 2}); + expect(restored.keyPosition(1), movedA); + await tester.drag( + find.byKey(const ValueKey('game-key-drag-1')), + const Offset(-3000, -3000), + ); + await tester.pumpAndSettle(); + expect(workspace.keyPosition(1).dx, greaterThanOrEqualTo(0)); + expect(workspace.keyPosition(1).dy, greaterThanOrEqualTo(0)); + expect(commands, 0); + await tester.pumpWidget(const SizedBox()); + workspace.dispose(); + restored.dispose(); + }, + ); + + testWidgets( + 'Hover preserves card geometry and explicit disabled actions stay disabled', + (tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final workspace = TableWorkspace(); + var commands = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: KeyBoxDesigner( + status: miniStatus(), + workspace: workspace, + enabled: true, + onTap: (_) => commands++, + openSettings: () {}, + ), + ), + ), + ); + final finder = find.byKey(const ValueKey('game-key-1')); + final before = tester.getRect(finder); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(2, 2)); + await mouse.moveTo(before.center); + await tester.pumpAndSettle(); + expect(tester.getRect(finder), before); + await tester.tap(find.byKey(const ValueKey('game-key-drag-3'))); + expect(commands, 0); + await tester.tap(find.byTooltip('Box 3 actions')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.widgetWithText(ListTile, 'Trigger box')) + .enabled, + false, + ); + await tester.pumpWidget(const SizedBox()); + workspace.dispose(); + }, + ); + + testWidgets( + 'Phone large text and tablet game workspace fit without overflows', + (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final workspace = TableWorkspace(); + for (final width in [380.0, 1200.0]) { + tester.view.physicalSize = Size(width, 1000); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 1000), + textScaler: TextScaler.linear(width < 500 ? 1.3 : 1), + ), + child: Scaffold( + body: MiniGamePanel( + status: miniStatus(), + connected: true, + command: (_, _) async => true, + openBoxSettings: () {}, + workspace: workspace, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const ValueKey('game-key-9'))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox()); + workspace.dispose(); + }, + ); + + testWidgets( + 'Capture the actual game workspace with published dual-group RGB', + (tester) async { + final path = Platform.environment['PAKAR_MOBILE_CAPTURE_PATH']; + if (path == null) return; + for (final font in ['Vazirmatn', 'MaterialIcons']) { + await (FontLoader(font)..addFont( + rootBundle.load( + font == 'MaterialIcons' + ? 'fonts/MaterialIcons-Regular.otf' + : 'assets/fonts/Vazirmatn-Regular.ttf', + ), + )) + .load(); + } + tester.view.physicalSize = const Size(1200, 1060); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final base = miniStatus().json; + final status = DeviceStatus({ + ...base, + 'winner': 2, + 'key_game': {...base['key_game'] as Map, 'enabled_mask': 63}, + 'minigame': { + ...base['minigame'] as Map, + 'phase': 'success', + 'winner': 2, + }, + 'pixel_frame': { + 'revision': 1, + 'pending': false, + 'physical': List.generate(18, (i) => i + 1), + 'rgb': List.generate( + 18, + (g) => List.generate( + 12, + (p) => g == 2 || g == 3 + ? (p == 2 ? '00b85a' : '35ef91') + : g < 12 + ? (p == 3 ? 'a81135' : 'ff4264') + : '000000', + ).join(), + ).join(), + }, + }); + final workspace = TableWorkspace(), boundary = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: studioTheme(palette: const {'primary': 0x38d27a}), + home: RepaintBoundary( + key: boundary, + child: Scaffold( + body: MiniGamePanel( + status: status, + connected: true, + command: (_, _) async => true, + openBoxSettings: () {}, + workspace: workspace, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.runAsync(() async { + final render = + boundary.currentContext!.findRenderObject() + as RenderRepaintBoundary; + final image = await render.toImage(pixelRatio: 1); + final png = await image.toByteData(format: ui.ImageByteFormat.png); + await File(path).writeAsBytes(png!.buffer.asUint8List(), flush: true); + image.dispose(); + }); + await tester.pumpWidget(const SizedBox()); + workspace.dispose(); + }, + ); +} diff --git a/mobile/test/minigame_test.dart b/mobile/test/minigame_test.dart new file mode 100644 index 00000000..4ad20374 --- /dev/null +++ b/mobile/test/minigame_test.dart @@ -0,0 +1,416 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/command_wire.dart'; +import 'package:pakarcontroller/minigame.dart'; +import 'package:pakarcontroller/minigame_panel.dart'; +import 'package:pakarcontroller/models.dart'; +import 'package:pakarcontroller/output_panel.dart'; + +import 'output_panel_test.dart' show keyObservation; + +DeviceStatus miniStatus({ + bool ready = true, + String phase = 'ready', + String board = 'keys-A', + String mode = 'press-to-win', + String toggleKind = 'toggle', +}) { + final base = keyObservation(board: board).json; + return DeviceStatus({ + ...base, + 'command_clock_ms': 500, + 'capabilities': { + ...base['capabilities'] as Map, + 'acknowledged_features': [ + ...(base['capabilities'] as Map)['acknowledged_features'] as List, + 'command-ack', + ], + }, + 'minigame': { + 'ready': ready, + 'mode': mode, + 'phase': phase, + 'revision': 2, + 'available_modes': [ + {'id': 'press-to-win', 'label': 'Press to win'}, + {'id': 'toggle', 'label': 'Switches'}, + ], + 'timed': true, + 'elapsed_ms': 1500, + 'remaining_ms': 58500, + 'participating_mask': 3, + 'held_mask': 0, + 'latched_mask': 0, + 'winner': 0, + 'target': 1, + 'round': 1, + 'progress': 2, + 'mistakes': 0, + 'warning': 0, + 'last_reaction_ms': 200, + 'best_reaction_ms': 150, + 'settings': { + 'mode': mode, + 'limit_ms': 60000, + 'warning_ms': 10000, + 'urgent_ms': 3000, + 'penalty_ms': 2000, + 'window_ms': 1200, + 'show_ms': 500, + 'gap_ms': 200, + 'feedback_ms': 240, + 'rounds': 5, + 'sequence_length': 4, + 'palette_size': 7, + 'fixed_target': 0, + 'toggle_kind': toggleKind, + 'hard': false, + 'outputs': List.generate(8, (_) => {'source': 'disabled', 'key': 0}), + }, + }, + }); +} + +Widget fixture( + DeviceStatus status, + MiniGameCommand command, { + bool connected = true, +}) => MaterialApp( + home: Scaffold( + body: OutputPanel( + section: OutputPanelSection.game, + status: status, + connected: connected, + command: (path, [values = const {}]) async { + await command(path, values); + }, + verifiedCommand: command, + openDiagnostics: () {}, + ), + ), +); + +void main() { + test( + 'Receipt owner survives the input fence without admitting a new command', + () { + final ready = miniStatus(), fenced = miniStatus(ready: false); + expect( + commandPeerMatches( + fenced, + ready.json['board_id'], + ready.json['boot_id'], + ), + isTrue, + ); + expect(fenced.peer.allows(minigameControl, {'action': 'start'}), isFalse); + expect( + commandPeerMatches( + miniStatus(board: 'other'), + ready.json['board_id'], + ready.json['boot_id'], + ), + isFalse, + ); + expect(commandPeerMatches(null, null, null), isFalse); + }, + ); + test('Intro and arpeggio settings are typed, bounded and optional', () { + final snapshot = miniStatus().peer.minigame!; + final fields = { + 'settings': '{"intro_enabled":true,"color_arpeggio":true,"intro_ms":1000,"preview_ms":1500,"mismatch_ms":1200}', + }; + expect(snapshot.allows(minigameConfigure, fields), isTrue); + expect( + snapshot.allows(minigameConfigure, {'settings': '{"mismatch_ms":100}'}), + isFalse, + ); + expect( + snapshot.allows(minigameConfigure, { + 'settings': '{"color_arpeggio":"true"}', + }), + isFalse, + ); + expect(snapshot.description('toggle'), contains('without a timer')); + }); + testWidgets('Untimed press to win has no elapsed countdown', (tester) async { + final base = miniStatus().json; + final status = DeviceStatus({ + ...base, + 'minigame': { + ...base['minigame'] as Map, + 'timed': false, + 'remaining_ms': 0, + 'settings': { + ...(base['minigame'] as Map)['settings'] as Map, + 'limit_ms': 0, + }, + }, + }); + await tester.pumpWidget(fixture(status, (_, _) async => true)); + await tester.pump(); + expect(find.textContaining('Elapsed:'), findsNothing); + expect(find.textContaining('Remaining:'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( + 'Advertised unready service never falls back to legacy game presses', + (tester) async { + final commands = []; + await tester.pumpWidget( + fixture(miniStatus(ready: false), (path, values) async { + commands.add(path); + return true; + }), + ); + expect(find.byKey(const ValueKey('key-layout-canvas')), findsOneWidget); + expect(find.byKey(const ValueKey('game-key-drag-1')), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('minigame-key-1')), + ) + .onTap, + isNull, + ); + expect( + tester + .widget(find.byKey(const ValueKey('minigame-start'))) + .onPressed, + isNull, + ); + expect(commands, isEmpty); + }, + ); + test('Minigame command admission requires advertised ready snapshot and bounded settings', () { + expect( + keyObservation().peer.allows(minigameControl, {'action': 'start'}), + isFalse, + ); + expect( + miniStatus(ready: false).peer + .allows(minigameControl, {'action': 'start'}), + isFalse, + ); + final status = miniStatus(); + expect( + status.peer.allows(minigameConfigure, { + 'settings': '{"mode":"invented"}', + }), + isFalse, + ); + expect( + status.peer.allows(minigameConfigure, {'settings': '{"enabled_mask":1}'}), + isFalse, + ); + expect( + status.peer.allows(minigameConfigure, {'settings': '{"window_ms":0}'}), + isFalse, + ); + expect( + status.peer.allows(minigameConfigure, { + 'settings': '{"urgent_ms":10001}', + }), + isFalse, + ); + final packet = jsonDecode( + commandPacket(status, minigameConfigure, { + 'settings': '{"mode":"toggle","hard":true}', + }), + ) as Map; + expect(packet['command'], 'minigame-configure'); + expect(packet['settings'], {'mode': 'toggle', 'hard': true}); + expect(packet['board_id'], 'keys-A'); + expect(packet['expires_at_ms'], 8500); + final input = jsonDecode( + commandPacket(status, minigameInput, {'key': '2', 'action': 'down'}), + ) as Map; + expect(input['key'], 2); + expect(input['action'], 'down'); + }); + + test('Saved settings readback succeeds during an unready input fence', () { + final status = miniStatus(ready: false); + final raw = status.json['minigame']; + final values = {'settings': '{"limit_ms":60000,"hard":false}'}; + expect(minigameReadbackMatches(raw, minigameConfigure, values), isTrue); + expect(status.peer.allows(minigameConfigure, values), isFalse); + expect( + minigameReadbackMatches(raw, minigameConfigure, { + 'settings': '{"limit_ms":61000}', + }), + isFalse, + ); + expect( + minigameReadbackMatches(raw, minigameConfigure, { + 'settings': '{"window_ms":0}', + }), + isFalse, + ); + }); + + test( + 'Settings readback compares every output position and rejects mismatches', + () { + final status = miniStatus(); + final settings = status.peer.minigame!.settings; + final outputs = List>.generate( + 8, + (_) => {'source': 'disabled', 'key': 0}, + ); + outputs[3] = {'source': 'held', 'key': 2}; + final values = { + 'settings': jsonEncode({'outputs': outputs}), + }; + expect( + minigameReadbackMatches( + status.json['minigame'], + minigameConfigure, + values, + ), + isFalse, + ); + final updated = { + ...status.json['minigame'] as Map, + 'settings': {...settings, 'outputs': outputs}, + }; + expect( + minigameReadbackMatches(updated, minigameConfigure, values), + isTrue, + ); + expect(validOutputs([...outputs.take(7)]), isFalse); + }, + ); + + testWidgets( + 'Opening never starts and controls use only board catalog and live phase', + (tester) async { + final commands = <(String, Map)>[]; + Future command(String path, Map values) async { + commands.add((path, values)); + return true; + } + + await tester.pumpWidget(fixture(miniStatus(), command)); + expect(commands, isEmpty); + expect(find.text('Remaining: 58.5 s'), findsOneWidget); + expect(find.text('Progress: 2'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('minigame-mode'))); + await tester.pumpAndSettle(); + expect(find.text('Switches'), findsOneWidget); + expect(find.text('Simon'), findsNothing); + await tester.tap(find.text('Switches')); + await tester.pumpAndSettle(); + expect(commands.single.$1, minigameConfigure); + expect(jsonDecode(commands.single.$2['settings']!), {'mode': 'toggle'}); + await tester.tap(find.byKey(const ValueKey('minigame-start'))); + await tester.pumpAndSettle(); + expect(commands.last.$1, minigameControl); + expect(commands.last.$2, {'action': 'start'}); + expect( + tester + .widget(find.byKey(const ValueKey('minigame-pause'))) + .onPressed, + isNull, + ); + await tester.pumpWidget(fixture(miniStatus(phase: 'paused'), command)); + await tester.tap(find.byKey(const ValueKey('minigame-resume'))); + await tester.pumpAndSettle(); + expect(commands.last.$1, minigameControl); + expect(commands.last.$2, {'action': 'resume'}); + }, + ); + + testWidgets( + 'Tap and hold release remain serialized while down ACK is pending', + (tester) async { + final commands = <(String, Map)>[]; + final down = Completer(); + await tester.pumpWidget( + fixture( + miniStatus(mode: 'toggle', phase: 'input', toggleKind: 'press'), + (path, values) async { + commands.add((path, values)); + return values['action'] == 'down' ? down.future : true; + }, + ), + ); + await tester.ensureVisible(find.byKey(const ValueKey('minigame-key-1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('minigame-key-1'))); + await tester.pumpAndSettle(); + expect(commands.single.$1, minigameInput); + expect(commands.single.$2, {'key': '1', 'action': 'tap'}); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('minigame-key-2'))), + ); + await tester.pump(const Duration(milliseconds: 600)); + expect(commands.last.$1, minigameInput); + expect(commands.last.$2, {'key': '2', 'action': 'down'}); + await gesture.up(); + await tester.pump(); + expect(commands.length, 2); + down.complete(true); + await tester.pumpAndSettle(); + expect(commands.last.$1, minigameInput); + expect(commands.last.$2, {'key': '2', 'action': 'up'}); + expect(commands.length, 3); + }, + ); + + testWidgets('Disabled and disconnected keys do not issue input', ( + tester, + ) async { + final commands = []; + Future command(String path, Map values) async { + commands.add(path); + return true; + } + + await tester.pumpWidget(fixture(miniStatus(), command)); + await tester.ensureVisible(find.byKey(const ValueKey('minigame-key-3'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('minigame-key-3'))); + await tester.pumpAndSettle(); + expect(commands, isEmpty); + await tester.pumpWidget(fixture(miniStatus(), command, connected: false)); + await tester.ensureVisible(find.byKey(const ValueKey('minigame-key-1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('minigame-key-1'))); + await tester.pumpAndSettle(); + expect(commands, isEmpty); + }); + + testWidgets( + 'Invalid settings stay local and a failed save retains the editor', + (tester) async { + final commands = >[]; + await tester.pumpWidget( + fixture(miniStatus(), (path, values) async { + commands.add(values); + return false; + }), + ); + await tester.tap(find.text('Game settings')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('minigame-setting-limit_ms')), + '-1', + ); + await tester.tap(find.text('Save to board')); + await tester.pumpAndSettle(); + expect(commands, isEmpty); + await tester.enterText( + find.byKey(const ValueKey('minigame-setting-limit_ms')), + '12000', + ); + await tester.tap(find.text('Save to board')); + await tester.pumpAndSettle(); + expect(jsonDecode(commands.single['settings']!), {'limit_ms': 12000}); + expect(find.text('Save to board'), findsOneWidget); + }, + ); +} diff --git a/mobile/test/minigame_transport_test.dart b/mobile/test/minigame_transport_test.dart new file mode 100644 index 00000000..39278597 --- /dev/null +++ b/mobile/test/minigame_transport_test.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/api.dart'; +import 'package:pakarcontroller/minigame.dart'; + +import 'minigame_test.dart' show miniStatus; + +void main() { + test('Minigame post uses one correlated command endpoint with typed settings and readback', () async { + final state = + jsonDecode(jsonEncode(miniStatus().json)) as Map; + state['boot_id'] = 'a' * 32; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final commands = >[], paths = []; + final api = PakarApi(baseUrl: 'http://127.0.0.1:${server.port}'); + final listener = server.listen((request) async { + request.response.headers.contentType = ContentType.json; + if (request.method == 'POST') { + paths.add(request.uri.path); + final fields = Uri.splitQueryString( + await utf8.decoder.bind(request).join(), + ); + final packet = jsonDecode(fields['packet']!) as Map; + commands.add(packet); + final game = state['minigame'] as Map; + (game['settings'] as Map).addAll(packet['settings'] as Map); + game['revision'] = 3; + request.response.write( + jsonEncode({ + 'type': 'command-result', + 'request_id': packet['request_id'], + 'board_id': state['board_id'], + 'boot_id': state['boot_id'], + 'accepted': true, + 'status': state, + }), + ); + } else { + request.response.write(jsonEncode(state)); + } + await request.response.close(); + }); + try { + await api.status(); + final result = await api.postJson(minigameConfigure, { + 'settings': '{"limit_ms":30000,"hard":true}', + }, authorize: () => true); + expect(paths, ['/api/command']); + expect(commands.single['command'], 'minigame-configure'); + expect(commands.single['settings'], {'limit_ms': 30000, 'hard': true}); + expect( + (result['status'] as Map)['minigame']['settings']['limit_ms'], + 30000, + ); + } finally { + await api.close(); + await listener.cancel(); + await server.close(force: true); + } + }); +} diff --git a/mobile/test/network_tools_page_test.dart b/mobile/test/network_tools_page_test.dart new file mode 100644 index 00000000..bd592134 --- /dev/null +++ b/mobile/test/network_tools_page_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/advanced_page.dart'; +import 'package:pakarcontroller/api.dart'; +import 'package:pakarcontroller/command_wire.dart'; +import 'package:pakarcontroller/network_tools_page.dart'; + +import 'dart:convert'; + +import 'peer_fixture.dart'; + +class NetworkToolsFixture extends PakarApi { + NetworkToolsFixture() : super(enforcePeerVersions: false); + final writes = >[]; + final settings = { + 'ntp_enabled': true, + 'ntp_dhcp': true, + 'ntp_server': 'pool.ntp.org', + 'http_date_url': 'https://example.com/', + 'http_date_automatic': false, + 'public_ip_url': 'https://api.ipify.org/', + 'public_ip_automatic': false, + 'refresh_seconds': 3600, + }; + @override + Future> getJson(String path) async => { + 'settings': {...settings}, + 'clock': {'valid': true, 'utc_epoch': 1793190896, 'source': 'ntp'}, + 'observed': {'public_ip_observed': false}, + 'job': {'busy': false, 'phase': 'idle'}, + }; + @override + Future> networkToolsWrite( + String path, + Map body, + ) async { + writes.add({'path': path, ...body}); + if (path.endsWith('/tools')) settings.addAll(body); + return getJson('/api/network/tools'); + } +} + +void main() { + test('Network command uses the shared typed correlated command envelope', () { + final fixture = peerFixture( + features: ['network', 'network-tools', 'command-ack'], + ); + final status = fixture.json + ..addAll({ + 'board_id': 'esp32s3-001122334455', + 'boot_id': '0123456789abcdef0123456789abcdef', + 'command_clock_ms': 1234, + }); + final packet = jsonDecode( + commandPacket(fixture, '/api/network/tools', { + 'settings': jsonEncode({ + 'ntp_enabled': false, + 'ntp_server': 'time.example.org', + }), + }), + ) as Map; + expect(packet['settings'], { + 'ntp_enabled': false, + 'ntp_server': 'time.example.org', + }); + expect(packet['expires_at_ms'], 9234); + expect(packet['board_id'], status['board_id']); + expect(packet['command'], 'configure-network-tools'); + }); + testWidgets('Advanced has distinct destinations and opens without a write', ( + tester, + ) async { + final api = NetworkToolsFixture(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdvancedPage( + api: api, + observed: () => peerFixture(features: ['network-tools']), + openSettings: (_) {}, + ), + ), + ), + ); + expect(find.text('Clock & network tools'), findsOneWidget); + expect(find.text('Remote connections'), findsOneWidget); + expect(find.text('App bridge & repeater'), findsOneWidget); + await tester.tap(find.text('Clock & network tools')); + await tester.pumpAndSettle(); + expect(find.text('NTP server / fallback'), findsOneWidget); + expect(api.writes, isEmpty); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets( + 'Network tools preserve drafts on refresh and explicitly save typed settings', + (tester) async { + final api = NetworkToolsFixture(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: NetworkToolsPage(api: api)), + ), + ); + await tester.pumpAndSettle(); + final server = find.widgetWithText(TextField, 'NTP server / fallback'); + await tester.enterText(server, 'time.example.org'); + await tester.tap(find.byTooltip('Refresh observed values')); + await tester.pumpAndSettle(); + expect(find.text('time.example.org'), findsOneWidget); + expect(api.writes, isEmpty); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(api.writes.single['ntp_server'], 'time.example.org'); + expect(api.writes.single.containsKey('ntp_enabled'), false); + expect(api.writes.single.containsKey('refresh_seconds'), false); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/mobile/test/output_panel_test.dart b/mobile/test/output_panel_test.dart index 3387f048..257dceeb 100644 --- a/mobile/test/output_panel_test.dart +++ b/mobile/test/output_panel_test.dart @@ -11,7 +11,222 @@ import 'package:pakarcontroller/workspace_activity_strip.dart'; import 'peer_fixture.dart'; +DeviceStatus keyObservation({ + bool avr = true, + bool mask = true, + bool press = true, + bool programming = false, + String board = 'keys-A', +}) { + final base = peerFixture( + kind: 'keys', + features: [ + 'leds', + 'inputs', + 'outputs', + 'key-settings', + if (press) 'game-press', + ], + ).json; + return DeviceStatus({ + ...base, + 'board_id': board, + 'boot_id': 'boot-A', + 'mode': 'first-wins', + 'winner': 0, + 'avr_online': avr, + 'key_mask_applied': mask, + 'programming_latch': programming, + 'key_game': { + 'enabled_mask': 3, + 'enabled_count': 2, + 'idle_rgb': 0xffffff, + 'win_rgb': 0x00ff00, + 'lose_rgb': 0xff0000, + 'extended_effects': true, + 'auto_reset_ms': 0, + }, + 'capabilities': { + ...base['capabilities'] as Map, + 'role': 'first-wins', + 'keys': {'count': 9, 'groups_per_key': 2, 'pixels_per_group': 12}, + 'modes': [ + {'id': 'first-wins', 'code': 2, 'kind': 'game', 'label': 'First wins'}, + ], + }, + }); +} + void main() { + testWidgets('Game boxes tap to press and hold for explicit actions', ( + tester, + ) async { + final commands = <(String, Map)>[]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutputPanel( + section: OutputPanelSection.game, + status: keyObservation(), + connected: true, + command: (path, [values = const {}]) async => + commands.add((path, values)), + openDiagnostics: () {}, + ), + ), + ), + ); + await tester.tap(find.byKey(const ValueKey('game-key-drag-1'))); + await tester.pumpAndSettle(); + expect(commands.length, 1); + expect(commands.last.$1, '/api/game/press'); + expect(commands.last.$2, {'key': '1'}); + await tester.longPress(find.byKey(const ValueKey('game-key-drag-2'))); + await tester.pumpAndSettle(); + expect( + commands.length, + 1, + reason: 'Opening actions must never trigger a box', + ); + await tester.tap(find.text('Trigger box')); + await tester.pumpAndSettle(); + expect(commands.last.$1, '/api/game/press'); + expect(commands.last.$2, {'key': '2'}); + await tester.longPress(find.byKey(const ValueKey('game-key-drag-1'))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Reset to white')); + await tester.pumpAndSettle(); + expect(commands.last.$1, '/api/game/reset'); + expect(commands.last.$2, isEmpty); + expect(commands.length, 3); + expect(tester.takeException(), isNull); + }); + + testWidgets('Unavailable game actions explain why without sending', ( + tester, + ) async { + var commands = 0; + for (final scenario in [ + (keyObservation(avr: false), true, 1, 'The AVR is offline.'), + ( + keyObservation(mask: false), + true, + 1, + 'Waiting for AVR to apply enabled boxes.', + ), + ( + keyObservation(press: false), + true, + 1, + 'This board has not enabled remote button presses.', + ), + ( + keyObservation(programming: true), + true, + 1, + 'AVR programming is active.', + ), + (keyObservation(), false, 1, 'Reconnect to control the game.'), + (keyObservation(), true, 3, 'Box 3 is disabled in Game settings.'), + ]) { + await tester.pumpWidget( + MaterialApp( + key: UniqueKey(), + home: Scaffold( + body: OutputPanel( + section: OutputPanelSection.game, + status: scenario.$1, + connected: scenario.$2, + command: (_, [values = const {}]) async => commands++, + openDiagnostics: () {}, + ), + ), + ), + ); + await tester.tap(find.byKey(ValueKey('game-key-drag-${scenario.$3}'))); + await tester.pumpAndSettle(); + expect(find.text(scenario.$4), findsOneWidget); + expect(commands, 0); + } + }); + + testWidgets( + 'Pending presses are not duplicated and unconfirmed commands are reported', + (tester) async { + final ack = Completer(); + var commands = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutputPanel( + section: OutputPanelSection.game, + status: keyObservation(), + connected: true, + command: (_, [values = const {}]) async => + fail('Use verified command'), + verifiedCommand: (_, values) { + commands++; + return ack.future; + }, + openDiagnostics: () {}, + ), + ), + ), + ); + final box = find.byKey(const ValueKey('game-key-drag-1')); + await tester.tap(box); + await tester.pump(); + await tester.tap(box); + await tester.pump(); + expect(commands, 1); + ack.complete(false); + await tester.pumpAndSettle(); + expect( + find.text( + 'The board did not confirm the command. It was not replayed.', + ), + findsOneWidget, + ); + }, + ); + + testWidgets('Box action sheet cannot send to a replacement peer', ( + tester, + ) async { + var status = keyObservation(); + var commands = 0; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (_, setState) { + update = setState; + return OutputPanel( + section: OutputPanelSection.game, + status: status, + connected: true, + command: (_, [values = const {}]) async => commands++, + openDiagnostics: () {}, + ); + }, + ), + ), + ), + ); + await tester.longPress(find.byKey(const ValueKey('game-key-drag-1'))); + await tester.pumpAndSettle(); + update(() => status = keyObservation(board: 'keys-B')); + await tester.pump(); + await tester.tap(find.text('Trigger box')); + await tester.pumpAndSettle(); + expect(commands, 0); + expect( + find.text('The connected board changed. Open the box actions again.'), + findsOneWidget, + ); + }); + testWidgets( 'Phone activity navigation keeps eight panels reachable without squeezing', (tester) async { @@ -296,7 +511,12 @@ void main() { (status.json['capabilities'] as Map)['modes'] = [ {'id': 'cell', 'code': 1, 'kind': 'presentation', 'label': 'Cells'}, if (multiple) - {'id': 'first-wins', 'code': 2, 'kind': 'game', 'label': 'First wins'}, + { + 'id': 'first-wins', + 'code': 2, + 'kind': 'game', + 'label': 'First wins', + }, ]; return status; } @@ -407,6 +627,7 @@ void main() { status: status, connected: true, workspace: workspace, + section: OutputPanelSection.game, command: (_, [values = const {}]) async => commands++, openDiagnostics: () {}, ), @@ -421,7 +642,7 @@ void main() { expect( tester .widget( - find.widgetWithText(FilterChip, 'Arrange boxes'), + find.byKey(const ValueKey('key-designer-arrange')), ) .selected, true, @@ -443,10 +664,22 @@ void main() { final restored = TableWorkspace(saved: workspace.snapshot()); expect(restored.keyPosition(1), workspace.keyPosition(1)); restored.dispose(); - await tester.drag( - find.byType(ReorderableListView), - const Offset(0, -900), + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutputPanel( + key: const ValueKey('outputs'), + status: status, + connected: true, + workspace: workspace, + command: (_, [values = const {}]) async => commands++, + openDiagnostics: () {}, + ), + ), + ), ); + expect(find.byKey(const ValueKey('key-layout-canvas')), findsNothing); + await tester.ensureVisible(find.text('Single pixel or 12-pixel group')); await tester.pumpAndSettle(); expect(find.text('Single pixel or 12-pixel group'), findsOneWidget); await tester.pumpWidget(const SizedBox()); diff --git a/mobile/test/panel_inventory_test.dart b/mobile/test/panel_inventory_test.dart index 8c23af9e..6b27e72b 100644 --- a/mobile/test/panel_inventory_test.dart +++ b/mobile/test/panel_inventory_test.dart @@ -40,6 +40,50 @@ void main() { FlutterSecureStorage.setMockInitialValues({}); }); + testWidgets('First-wins shortcut opens Game without inventing a Table', ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fixture = peerFixture( + kind: 'keys', + hostname: 'keycontroller', + title: 'Pakar Keys', + application: 'pakar-keys', + features: const ['leds', 'inputs', 'outputs', 'key-settings'], + ); + final keys = DeviceStatus({ + ...fixture.json, + 'mode': 'first-wins', + 'capabilities': { + ...fixture.json['capabilities'] as Map, + 'role': 'first-wins', + 'keys': {'count': 9, 'groups_per_key': 2, 'pixels_per_group': 12}, + }, + }); + final inventory = PanelInventory()..observe(keys); + expect(inventory.output, true); + expect(inventory.game, true); + expect(inventory.tableSelection, false); + expect(inventory.has('table'), false); + final api = LifecycleApi() + ..healthyWs = true + ..observed = keys; + await tester.pumpWidget(PakarControllerApp(api: api)); + await tester.pumpAndSettle(); + expect(find.text('Game controls'), findsOneWidget); + await tester.tap(find.text('Game controls')); + await tester.pumpAndSettle(); + expect(find.text('Game controls'), findsNothing); + expect(find.text('Game'), findsWidgets); + expect(find.byKey(const ValueKey('key-layout-canvas')), findsOneWidget); + expect(find.byType(TablePage), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); + test('Retained panel identity is presentation only, never a fresh capability ACK', () { final inventory = PanelInventory()..observe(observation()); final retainedOwner = inventory.ownerKey; diff --git a/mobile/test/peer_context_test.dart b/mobile/test/peer_context_test.dart index 75be0969..330a0b8d 100644 --- a/mobile/test/peer_context_test.dart +++ b/mobile/test/peer_context_test.dart @@ -8,6 +8,47 @@ import 'package:pakarcontroller/peer_context.dart'; import 'peer_fixture.dart'; void main() { + test('Advertised game press and presentation survive capability parsing', () { + final raw = peerFixture( + kind: 'keys', + features: [ + 'inputs', + 'outputs', + 'key-settings', + 'game-press', + 'game-presentation', + ], + ).json; + (raw['capabilities'] as Map) + ..['role'] = 'first-wins' + ..['keys'] = {'count': 9, 'groups_per_key': 2, 'pixels_per_group': 12}; + final peer = DeviceStatus(raw).peer; + expect(peer.has('game-press'), true); + expect(peer.has('game-presentation'), true); + expect(peer.allows('/api/game/press', {'key': '1'}), true); + expect(peer.allows('/api/game/press', {'key': '9'}), true); + expect(peer.allows('/api/game/reset', {}), true); + for (final key in ['0', '10', '-1', '1,2']) { + expect(peer.allows('/api/game/press', {'key': key}), false); + } + (raw['capabilities'] as Map)['acknowledged_features'] = [ + 'inputs', + 'outputs', + ]; + expect( + DeviceStatus(raw).peer.allows('/api/game/press', {'key': '1'}), + false, + ); + expect(DeviceStatus(raw).peer.has('game-presentation'), false); + (raw['capabilities'] as Map) + ..['role'] = 'table-selection' + ..['acknowledged_features'] = ['game-press']; + expect( + DeviceStatus(raw).peer.allows('/api/game/press', {'key': '1'}), + false, + ); + }); + test('LED backend capability is explicit; omitted or invalid is unknown', () { for (final item in [ {'backend': 'gpio-static', 'rmt': false, 'dma': false}, @@ -118,8 +159,11 @@ void main() { }; final icon = PeerApplication.parse(json)!.icon; expect(icon, isNotNull); - expect(identical(PeerApplication.parse(Map.of(json))!.icon, icon), isTrue, - reason: 'Repeated telemetry must retain the MemoryImage cache key'); + expect( + identical(PeerApplication.parse(Map.of(json))!.icon, icon), + isTrue, + reason: 'Repeated telemetry must retain the MemoryImage cache key', + ); ByteData.sublistView(bytes).setUint32(16, 4096); json['icon_png_base64'] = base64Encode(bytes); expect(PeerApplication.parse(json)!.icon, isNull); diff --git a/mobile/test/pixel_correction_controls_test.dart b/mobile/test/pixel_correction_controls_test.dart new file mode 100644 index 00000000..03379acb --- /dev/null +++ b/mobile/test/pixel_correction_controls_test.dart @@ -0,0 +1,29 @@ +// Widget/model regressions stay separate from real HTTP/WS and USB ownership +// tests so their real-clock transports cannot inherit the widget fake clock. +import 'pixel_correction_default_preview_test.dart' as defaults; +import 'pixel_correction_menu_test.dart' as menu; +import 'pixel_correction_intent_test.dart' as intents; +import 'pixel_corrections_editor_test.dart' as editor; +import 'pixel_corrections_exit_test.dart' as exit_guard; +import 'pixel_correction_preview_test.dart' as lease; +import 'pixel_correction_mapping_test.dart' as mapping; +import 'pixel_corrections_test.dart' as corrections; +import 'avr_command_wire_test.dart' as avr; +import 'appearance_live_test.dart' as appearance; +import 'table_viewport_test.dart' as viewport; +import 'status_bar_layout_test.dart' as footer; + +void main() { + defaults.main(); + menu.main(); + intents.main(); + editor.main(); + exit_guard.main(); + lease.main(); + mapping.main(); + corrections.main(); + avr.main(); + appearance.main(); + viewport.main(); + footer.main(); +} diff --git a/mobile/test/pixel_correction_default_preview_test.dart b/mobile/test/pixel_correction_default_preview_test.dart new file mode 100644 index 00000000..8f279026 --- /dev/null +++ b/mobile/test/pixel_correction_default_preview_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/pixel_corrections_editor.dart'; +import 'package:pakarcontroller/pixel_corrections_page.dart'; +import 'package:pakarcontroller/pixel_diagnostic.dart'; + +import 'pixel_corrections_editor_test.dart' + show EditorApi, rule, tabletViewport; + +class UsbEditorApi extends EditorApi { + @override + bool get isUsb => true; +} + +Widget editor( + EditorApi api, + GlobalKey key, { + PixelCorrectionRuleIntent? intent, +}) => MaterialApp( + home: Scaffold( + body: PixelCorrectionsPage( + key: key, + api: api, + busyChanged: (_) {}, + incomingRule: intent, + ), + ), +); + +void main() { + testWidgets( + 'USB correction editor loads and previews using selected USB route', + (tester) async { + tabletViewport(tester); + final api = UsbEditorApi(); + addTearDown(api.close); + final key = GlobalKey(); + await tester.pumpWidget(editor(api, key)); + await tester.pumpAndSettle(); + expect(key.currentState!.saved, isNotNull); + expect(key.currentState!.error, isNull); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + expect(api.activePreview, isNotNull); + await tester.tap(find.text('Save to board')); + await tester.pumpAndSettle(); + expect(api.saves, 1); + await tester.pumpWidget(const SizedBox()); + }, + ); + testWidgets( + 'revision-bound handoff cannot reinterpret an existing dirty rule sequence', + (tester) async { + tabletViewport(tester); + final api = EditorApi(); + addTearDown(api.close); + final key = GlobalKey(); + await tester.pumpWidget(editor(api, key)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + final count = api.requests.length; + await tester.pumpWidget( + editor( + api, + key, + intent: PixelCorrectionRuleIntent( + kind: 'black', + pixel: 334, + revision: 3, + ), + ), + ); + await tester.pumpAndSettle(); + expect(key.currentState!.rules, hasLength(1)); + expect(key.currentState!.error, contains('mapping changed')); + expect(api.requests.length, count); + await tester.pumpWidget(const SizedBox()); + }, + ); + test('preview preference roundtrips through the existing per-board workspace without persisting a lease', () { + final workspace = PixelDiagnosticWorkspace(); + expect(workspace.correctionLivePreview, true); + var changes = 0; + workspace.addListener(() => changes++); + workspace.correctionLivePreview = false; + final restored = PixelDiagnosticWorkspace(saved: workspace.snapshot()); + expect(restored.correctionLivePreview, false); + expect(changes, 1); + expect(workspace.snapshot().containsKey('lease_id'), false); + expect(restored.observation, isNull); + }); + testWidgets( + 'default preview stays read-only on open then edits RAM only; Save persists once', + (tester) async { + tabletViewport(tester); + final api = EditorApi(); + addTearDown(api.close); + final key = GlobalKey(); + await tester.pumpWidget(editor(api, key)); + await tester.pumpAndSettle(); + expect(key.currentState!.previewOnEdit, true); + expect(api.requests, isEmpty); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + expect(api.saves, 0); + expect(api.activePreview, isNotNull); + expect(api.requests.single['path'], '/api/pixels/corrections/preview'); + await tester.tap(find.text('Save to board')); + await tester.pumpAndSettle(); + expect(api.saves, 1); + expect(api.value['rules'], [rule('black', 0)]); + expect(api.activePreview, isNull); + expect(key.currentState!.dirty, false); + expect(key.currentState!.previewOnEdit, true); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'unchecked preview leaves edits local and never silently reacquires', + (tester) async { + tabletViewport(tester); + final api = EditorApi(); + addTearDown(api.close); + final key = GlobalKey(); + await tester.pumpWidget(editor(api, key)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('correction-live-preview'))); + await tester.tap(find.text('Add rule')); + await tester.pumpAndSettle(); + expect(key.currentState!.previewOnEdit, false); + expect(key.currentState!.dirty, true); + expect(api.requests, isEmpty); + await tester.pump(const Duration(seconds: 11)); + expect(api.requests, isEmpty); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'revision-bound diagnostic menu intent previews exact wire and never migrates saved rules', + (tester) async { + tabletViewport(tester); + final api = EditorApi()..value['rules'] = [rule('remove', 0)]; + addTearDown(api.close); + final key = GlobalKey(); + final intent = PixelCorrectionRuleIntent( + kind: 'black', + pixel: 334, + revision: 3, + ); + await tester.pumpWidget(editor(api, key, intent: intent)); + await tester.pumpAndSettle(); + expect(key.currentState!.rules, [rule('remove', 0), intent.toRule()]); + expect(api.value['rules'], [rule('remove', 0)]); + expect(api.saves, 0); + expect(api.activePreview!['rules'], [rule('remove', 0), intent.toRule()]); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'changed mapping revision rejects handoff without preview or saved writes', + (tester) async { + tabletViewport(tester); + final api = EditorApi(); + addTearDown(api.close); + final key = GlobalKey(); + await tester.pumpWidget( + editor( + api, + key, + intent: PixelCorrectionRuleIntent( + kind: 'black', + pixel: 334, + revision: 2, + ), + ), + ); + await tester.pumpAndSettle(); + expect(key.currentState!.rules, isEmpty); + expect(key.currentState!.error, contains('mapping changed')); + expect(api.requests, isEmpty); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets('existing rule cursor translates through prior draft rows only', ( + tester, + ) async { + tabletViewport(tester); + final api = EditorApi() + ..value['rules'] = [rule('remove', 0), rule('black', 8)]; + addTearDown(api.close); + final key = GlobalKey(); + await tester.pumpWidget(editor(api, key)); + await tester.pumpAndSettle(); + key.currentState!.selectedRule = key.currentState!.rules.last; + key.currentState!.importPixel(335); + await tester.pumpAndSettle(); + expect(key.currentState!.rules.last['pixel'], 334); + expect(api.saves, 0); + expect(api.value['rules'], [rule('remove', 0), rule('black', 8)]); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/mobile/test/pixel_correction_intent_test.dart b/mobile/test/pixel_correction_intent_test.dart index 5d28362e..d81fe224 100644 --- a/mobile/test/pixel_correction_intent_test.dart +++ b/mobile/test/pixel_correction_intent_test.dart @@ -41,6 +41,7 @@ Widget page( GlobalKey key, PixelCorrectionRuleIntent? intent, { bool active = true, + bool livePreview = false, }) => MaterialApp( home: Scaffold( body: PixelCorrectionsPage( @@ -49,6 +50,7 @@ Widget page( busyChanged: (_) {}, incomingRule: intent, active: active, + livePreviewInitiallyEnabled: livePreview, ), ), ); @@ -130,7 +132,7 @@ void main() { }); testWidgets( - 'new intent stops existing live preview and never sends its draft', + 'new explicit intent replaces only the owned RAM lease when preview is enabled', (tester) async { tabletViewport(tester); final api = IntentApi(); @@ -146,10 +148,11 @@ void main() { ); await tester.pumpAndSettle(); expect(key.currentState!.rules, hasLength(1)); - expect(key.currentState!.preview.enabled, false); - expect(api.activePreview, isNull); - expect(api.requests, hasLength(2)); - expect(api.requests.last['clear'], true); + expect(key.currentState!.preview.enabled, true); + expect(api.activePreview, isNotNull); + expect(api.requests, hasLength(3)); + expect(api.requests[1]['clear'], true); + expect((api.requests.last['rules'] as List).single['pixel'], 8); expect(api.saves, 0); await tester.pumpWidget(const SizedBox()); }, diff --git a/mobile/test/pixel_correction_mapping_test.dart b/mobile/test/pixel_correction_mapping_test.dart index 20c8a12e..6fc1f94a 100644 --- a/mobile/test/pixel_correction_mapping_test.dart +++ b/mobile/test/pixel_correction_mapping_test.dart @@ -169,6 +169,7 @@ Future> open( api: api, busyChanged: (_) {}, mapping: () => null, + livePreviewInitiallyEnabled: false, ), ), ), diff --git a/mobile/test/pixel_correction_menu_test.dart b/mobile/test/pixel_correction_menu_test.dart index 486e18bc..fe576cae 100644 --- a/mobile/test/pixel_correction_menu_test.dart +++ b/mobile/test/pixel_correction_menu_test.dart @@ -4,12 +4,56 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:pakarcontroller/main.dart'; import 'package:pakarcontroller/pixel_correction_menu.dart'; +import 'package:pakarcontroller/pixel_correction_address.dart'; import 'package:pakarcontroller/pixel_corrections_editor.dart'; import 'package:pakarcontroller/pixel_corrections_page.dart'; import 'pixel_tools_navigation_test.dart' show PixelMenuApi, pixelPeer; +class RemappedMenuApi extends PixelMenuApi { + List sources = List.generate(480, (index) => index); + @override + Future> getJson(String path) async => { + ...await super.getJson(path), + 'sources': sources, + }; +} + void main() { + test('diagnostic raw addresses invert saved output without changing persisted indexes', () { + final identity = List.generate(480, (index) => index); + expect(pixelCorrectionOutputSlots(raw: 0, pixels: 480, sources: identity), [ + 0, + ]); + expect( + pixelCorrectionOutputSlots(raw: 479, pixels: 480, sources: identity), + [479], + ); + final removed = pixelCorrectionSources(480, true, [ + {'kind': 'remove', 'pixel': 0, 'offset': 0}, + ]); + expect( + pixelCorrectionOutputSlots(raw: 335, pixels: 480, sources: removed), + [334], + ); + expect( + pixelCorrectionOutputSlots(raw: 0, pixels: 480, sources: removed), + isEmpty, + ); + expect( + pixelCorrectionOutputSlots(raw: 2, pixels: 4, sources: [0, 2, 2, -1]), + [1, 2], + ); + expect( + () => pixelCorrectionOutputSlots(raw: 0, pixels: 4, sources: [0, 1]), + throwsFormatException, + ); + expect( + () => + pixelCorrectionOutputSlots(raw: 0, pixels: 4, sources: [0, 1, 2, 4]), + throwsFormatException, + ); + }); testWidgets( 'Actual diagnostic long press opens exact correction draft without sending', (tester) async { @@ -62,13 +106,20 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); PixelCorrectionRuleIntent? selected; + final api = PixelMenuApi()..observed = pixelPeer(corrections: true); + addTearDown(api.close); await tester.pumpWidget( MaterialApp( home: Builder( builder: (context) => Scaffold( body: TextButton( onPressed: () async { - selected = await showPixelCorrectionMenu(context, 335); + selected = await showPixelCorrectionMenu( + context, + 335, + api: api, + authorize: () => true, + ); }, child: const Text('Open'), ), @@ -89,9 +140,64 @@ void main() { expect(selected?.pixel, 335); expect(selected?.kind, operation.$1); expect(selected?.offset, operation.$2); + expect(selected?.revision, 1); expect(tester.takeException(), isNull); }, ); } } + for (final ambiguous in [false, true]) { + testWidgets( + 'remapped diagnostic chooses correct wire, ambiguous=$ambiguous', + (tester) async { + final api = RemappedMenuApi()..observed = pixelPeer(corrections: true); + addTearDown(api.close); + api.sources = pixelCorrectionSources(480, true, [ + {'kind': 'remove', 'pixel': 0, 'offset': 0}, + ]); + if (ambiguous) api.sources[333] = 335; + PixelCorrectionRuleIntent? selected; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => + selected = await showPixelCorrectionMenu( + context, + 335, + api: api, + authorize: () => true, + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + if (ambiguous) { + expect( + tester + .widget( + find.byKey(const ValueKey('correction-menu-black-0')), + ) + .enabled, + false, + ); + await tester.tap(find.byKey(const Key('correction-output-choice'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Output slot 335').last); + await tester.pumpAndSettle(); + } + expect(find.text('Correct output slot 335'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('correction-menu-black-0'))); + await tester.pumpAndSettle(); + expect(selected?.pixel, 334); + expect(selected?.revision, 1); + expect(api.writes, 0); + }, + ); + } } diff --git a/mobile/test/pixel_correction_preview_transport_test.dart b/mobile/test/pixel_correction_preview_transport_test.dart new file mode 100644 index 00000000..9d5062af --- /dev/null +++ b/mobile/test/pixel_correction_preview_transport_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/api.dart'; +import 'package:pakarcontroller/pixel_correction_preview.dart'; +import 'package:pakarcontroller/pixel_corrections_editor.dart'; + +import 'peer_fixture.dart'; + +class PreviewServer { + PreviewServer({ + this.command = true, + this.reject = false, + this.foreign = false, + this.dropWs = false, + }); + final bool command, reject, foreign, dropWs; + late HttpServer server; + late StreamSubscription requests; + final sockets = []; + int httpWrites = 0, wsWrites = 0, executions = 0; + final receipts = >{}; + Map? active; + Map status() => { + ...peerFixture( + features: [ + 'leds', + 'table', + 'command-ack', + 'pixel-corrections', + 'pixel-corrections-preview', + if (command) 'pixel-corrections-preview-command', + ], + ).json, + 'board_id': 'preview-fixture', + 'boot_id': 'a' * 32, + 'command_clock_ms': 1000, + 'pixels': 480, + }; + Map preview() => { + 'active': active != null, + 'lease_id': active == null ? 0 : 41, + 'base_revision': 3, + 'remaining_ms': active == null ? 0 : 10000, + }; + Map config() => { + 'revision': 3, + 'enabled': true, + 'pixels': 480, + 'rules': [], + 'active_sources': pixelCorrectionSources( + 480, + active?['enabled'] == true, + (active?['rules'] as List? ?? const []) + .map((r) => Map.from(r as Map)) + .toList(), + ), + 'preview': preview(), + }; + Map apply(Map q) { + final id = q['request_id'] as String; + return receipts.putIfAbsent(id, () { + if (!reject) { + executions++; + final c = Map.from(q['configuration'] as Map); + active = c['clear'] == true ? null : c; + } + return { + 'type': 'command-result', + 'request_id': id, + 'board_id': 'preview-fixture', + 'boot_id': foreign ? 'b' * 32 : 'a' * 32, + 'accepted': !reject, + 'error': reject ? 'preview_owned' : null, + 'pixel_corrections_preview': preview(), + 'status': status(), + }; + }); + } + + Future start() async { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + requests = server.listen((request) async { + if (request.uri.path == '/ws') { + final socket = await WebSocketTransformer.upgrade(request); + sockets.add(socket); + socket.add(jsonEncode(status())); + socket.listen((raw) { + final q = jsonDecode(raw as String) as Map; + if (q['type'] == 'command') { + wsWrites++; + final reply = apply(q); + if (!dropWs) socket.add(jsonEncode(reply)); + } else { + socket.add(jsonEncode(status())); + } + }); + return; + } + request.response.headers.contentType = ContentType.json; + Object result; + if (request.method == 'POST') { + httpWrites++; + final fields = Uri.splitQueryString( + await utf8.decoder.bind(request).join(), + ); + if (request.uri.path == '/api/command') { + result = apply(jsonDecode(fields['packet']!) as Map); + } else if (request.uri.path == '/api/pixels/corrections/preview') { + executions++; + final c = + jsonDecode(fields['configuration']!) as Map; + active = c['clear'] == true ? null : c; + result = config(); + } else { + result = status(); + } + } else { + result = request.uri.path == '/api/pixels/corrections' + ? config() + : status(); + } + request.response.write(jsonEncode(result)); + await request.response.close(); + }); + } + + Future close() async { + for (final socket in sockets) { + await socket.close(); + } + await requests.cancel(); + await server.close(force: true); + } +} + +void main() { + for (final mode in [ + 'ws', + 'same-id-fallback', + 'old-http', + 'rejected', + 'foreign', + ]) { + test( + 'correction preview $mode preserves one intent and exact readback', + () async { + final server = PreviewServer( + command: mode != 'old-http', + reject: mode == 'rejected', + foreign: mode == 'foreign', + dropWs: mode == 'same-id-fallback', + ); + await server.start(); + final api = PakarApi(baseUrl: 'http://127.0.0.1:${server.server.port}'); + await api.status(); + final ready = Completer(); + final live = api.liveStatus().listen((_) { + if (!ready.isCompleted) ready.complete(); + }); + await ready.future.timeout(const Duration(seconds: 2)); + final controller = PixelCorrectionPreview( + api: api, + allowed: () => true, + pixels: () => 480, + configuration: () => { + 'revision': 3, + 'enabled': true, + 'rules': [ + {'kind': 'remove', 'pixel': 0, 'offset': 0}, + ], + }, + ); + try { + await controller.start(); + if (mode == 'rejected' || mode == 'foreign') { + expect(controller.enabled, false); + expect(controller.error, isNotNull); + // A foreign WS receipt cannot settle this intent. The permitted + // HTTP fallback retains the same peer fence and request id; it + // must never execute another mutation or accept foreign state. + expect(server.httpWrites, mode == 'foreign' ? 1 : 0); + expect(server.wsWrites, 1); + expect(server.receipts, hasLength(1)); + expect(server.executions, mode == 'foreign' ? 1 : 0); + } else { + expect(controller.enabled, true); + expect(controller.observed!['active_sources'].first, 1); + expect(server.executions, 1); + expect(server.wsWrites, mode == 'old-http' ? 0 : 1); + expect( + server.httpWrites, + (mode == 'old-http' || mode == 'same-id-fallback') ? 1 : 0, + ); + expect(server.receipts.length, mode == 'old-http' ? 0 : 1); + } + } finally { + await controller.stop(); + controller.dispose(); + await api.close(); + await live.cancel(); + await server.close(); + } + }, + ); + } +} diff --git a/mobile/test/pixel_correction_usb_test.dart b/mobile/test/pixel_correction_usb_test.dart new file mode 100644 index 00000000..e6cafeab --- /dev/null +++ b/mobile/test/pixel_correction_usb_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pakarcontroller/usb_transport.dart'; + +import 'usb_transport_test.dart' show UsbFixture; + +class CorrectionUsbFixture extends UsbFixture { + CorrectionUsbFixture() { + (status['capabilities']['acknowledged_features'] as List).addAll([ + 'usb-api', + 'pixel-corrections', + 'pixel-corrections-preview', + ]); + } + final request = []; + List response = []; + final paths = []; + final bodies = []; + bool reject = false; + @override + Future invoke(MethodCall call) async { + if (call.method != 'write') return super.invoke(call); + final bytes = call.arguments['bytes'] as Uint8List; + final frame = UsbFrame.decode(bytes.sublist(0, bytes.length - 1))!; + if (frame.opcode != 0x6a) return super.invoke(call); + writes.add(frame); + final p = frame.payload, action = p.first; + var offset = 0; + if (action == 0) { + request.clear(); + response = []; + } + if (action == 1) request.addAll(p.skip(7)); + if (action == 2) { + final envelope = jsonDecode(utf8.decode(request)) as Map; + expect(envelope['board_id'], status['board_id']); + expect(envelope['boot_id'], status['boot_id']); + paths.add(envelope['path'] as String); + bodies.add(envelope['body'] as Map); + response = utf8.encode( + jsonEncode( + reject + ? {'accepted': false, 'error': 'revision_conflict'} + : { + 'revision': 3, + 'pixels': 480, + 'enabled': true, + 'rules': bodies.last['rules'] ?? [], + 'preview': { + 'active': paths.last.endsWith('/preview'), + 'lease_id': 41, + }, + }, + ), + ); + } + if (action == 3) offset = UsbFixture.read(p, 5, 2); + if (action == 4) { + reply(frame, 0x80, [0x6a, 0]); + return bytes.length; + } + reply(frame, 0xbd, [ + action, + ...UsbFixture.le(73, 4), + ...UsbFixture.le(response.length, 2), + ...UsbFixture.le(offset, 2), + if (action == 3) ...response.skip(offset).take(39), + ]); + return bytes.length; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('USB correction read, preview and Save retain raw stored addresses and explicit paths', () async { + final fixture = CorrectionUsbFixture(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(fixture.channel, fixture.invoke); + try { + await fixture.transport.connect('usb://12/0'); + await fixture.transport.pixelCorrectionsApi(authorize: () => true); + final config = { + 'revision': 3, + 'enabled': true, + 'rules': [ + {'kind': 'black', 'pixel': 334, 'offset': 0}, + ], + }; + await fixture.transport.pixelCorrectionsApi( + configuration: {...config, 'ttl_ms': 10000}, + preview: true, + authorize: () => true, + ); + await fixture.transport.pixelCorrectionsApi( + configuration: config, + authorize: () => true, + ); + expect(fixture.paths, [ + '/api/pixels/corrections', + '/api/pixels/corrections/preview', + '/api/pixels/corrections', + ]); + expect((fixture.bodies[1]['rules'] as List).single['pixel'], 334); + expect(fixture.bodies[2].containsKey('ttl_ms'), false); + } finally { + await fixture.close(); + } + }); + test( + 'USB correction rejection and retired authorization never replay', + () async { + final fixture = CorrectionUsbFixture()..reject = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(fixture.channel, fixture.invoke); + try { + await fixture.transport.connect('usb://12/0'); + await expectLater( + fixture.transport.pixelCorrectionsApi( + configuration: {'revision': 3, 'enabled': true, 'rules': []}, + preview: true, + authorize: () => true, + ), + throwsFormatException, + ); + expect(fixture.paths, hasLength(1)); + await expectLater( + fixture.transport.pixelCorrectionsApi(authorize: () => false), + throwsFormatException, + ); + expect(fixture.paths, hasLength(1)); + } finally { + await fixture.close(); + } + }, + ); +} diff --git a/mobile/test/pixel_corrections_editor_test.dart b/mobile/test/pixel_corrections_editor_test.dart index 5ce1773f..3aa6afa9 100644 --- a/mobile/test/pixel_corrections_editor_test.dart +++ b/mobile/test/pixel_corrections_editor_test.dart @@ -120,6 +120,7 @@ Widget page( EditorApi api, { PixelDiagnosticWorkspace? workspace, bool active = true, + bool livePreview = false, }) => MaterialApp( debugShowCheckedModeBanner: false, theme: studioTheme(), @@ -129,6 +130,7 @@ Widget page( busyChanged: (_) {}, workspace: workspace, active: active, + livePreviewInitiallyEnabled: livePreview, ), ), ); diff --git a/mobile/test/pixel_corrections_exit_test.dart b/mobile/test/pixel_corrections_exit_test.dart index 10615b72..ac86f316 100644 --- a/mobile/test/pixel_corrections_exit_test.dart +++ b/mobile/test/pixel_corrections_exit_test.dart @@ -66,6 +66,7 @@ Widget workspace(ExitApi api, GlobalKey key) => key: key, api: api, active: index == 1, + livePreviewInitiallyEnabled: false, busyChanged: (value) => update(() => locked = value), ), diff --git a/mobile/test/pixel_diagnostic_test.dart b/mobile/test/pixel_diagnostic_test.dart index 4131c074..ed0a61dc 100644 --- a/mobile/test/pixel_diagnostic_test.dart +++ b/mobile/test/pixel_diagnostic_test.dart @@ -36,6 +36,7 @@ void main() { ); expect(workspace.probe.first, 474); expect(workspace.snapshot(), { + 'correction_live_preview': true, 'index': 479, 'group': true, 'rgb': 0x1234ab, diff --git a/mobile/test/pixel_tools_navigation_test.dart b/mobile/test/pixel_tools_navigation_test.dart index 09fc6be7..4e789036 100644 --- a/mobile/test/pixel_tools_navigation_test.dart +++ b/mobile/test/pixel_tools_navigation_test.dart @@ -40,7 +40,7 @@ class PixelMenuApi extends LifecycleApi { 'revision': 1, 'pixels': 480, 'rules': [], - 'sources': [0, 1, 2], + 'sources': List.generate(480, (index) => index), }; } } diff --git a/mobile/test/signal_indicator_test.dart b/mobile/test/signal_indicator_test.dart new file mode 100644 index 00000000..c6e2d0fc --- /dev/null +++ b/mobile/test/signal_indicator_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pakarcontroller/main.dart'; +import 'package:pakarcontroller/signal_indicator.dart'; + +import 'peer_fixture.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future show( + WidgetTester tester, { + int rssi = -57, + VoidCallback? onConnection, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SignalIndicator(rssi: rssi, onConnection: onConnection), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('Signal defaults to an icon and tapping opens connection', ( + tester, + ) async { + var opened = 0; + await show(tester, onConnection: () => opened++); + expect(find.byIcon(Icons.network_wifi_3_bar), findsOneWidget); + expect(find.text('-57 dBm'), findsNothing); + expect( + tester.getSize(find.byKey(const Key('status-signal'))), + const Size(48, 48), + ); + await tester.tap(find.byKey(const Key('status-signal'))); + expect(opened, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Long press selects either or both and persists across remounts', + (tester) async { + var opened = 0; + await show(tester, onConnection: () => opened++); + for (final (label, saved, hasIcon, hasValue) in [ + ('Icon and value', 'both', true, true), + ('Value only', 'value', false, true), + ('Icon only', 'icon', true, false), + ]) { + await tester.longPress(find.byKey(const Key('status-signal'))); + await tester.pumpAndSettle(); + expect(find.text('Icon only'), findsOneWidget); + expect(find.text('Value only'), findsOneWidget); + expect(find.text('Icon and value'), findsOneWidget); + await tester.tap(find.ancestor( + of: find.text(label), matching: find.byType(CheckedPopupMenuItem), + )); + await tester.pumpAndSettle(); + expect(opened, 0); + expect( + (await SharedPreferences.getInstance()).getString( + SignalIndicator.preferenceKey, + ), + saved, + ); + expect( + find.byIcon(Icons.network_wifi_3_bar), + hasIcon ? findsOneWidget : findsNothing, + ); + expect(find.text('-57 dBm'), hasValue ? findsOneWidget : findsNothing); + await tester.pumpWidget(const SizedBox()); + await show(tester, onConnection: () => opened++); + expect( + find.byIcon(Icons.network_wifi_3_bar), + hasIcon ? findsOneWidget : findsNothing, + ); + expect(find.text('-57 dBm'), hasValue ? findsOneWidget : findsNothing); + expect(tester.takeException(), isNull); + } + }, + ); + + testWidgets('Signal strength changes without resetting the display choice', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + SignalIndicator.preferenceKey: 'both', + }); + for (final (rssi, icon) in [ + (-50, Icons.signal_wifi_4_bar), + (-60, Icons.network_wifi_3_bar), + (-70, Icons.network_wifi_2_bar), + (-80, Icons.network_wifi_1_bar), + (-95, Icons.signal_wifi_0_bar), + (0, Icons.signal_wifi_0_bar), + ]) { + await show(tester, rssi: rssi); + expect(find.byIcon(icon), findsOneWidget); + expect(find.text(rssi == 0 ? '—' : '$rssi dBm'), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('status-signal'))).width, 116); + } + }); + + testWidgets( + 'Footer stays bounded on phones and tablets with signal and errors', + (tester) async { + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final display in SignalDisplay.values) { + SharedPreferences.setMockInitialValues({ + SignalIndicator.preferenceKey: display.name, + }); + for (final width in [320.0, 800.0]) { + for (final direction in TextDirection.values) { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + final status = peerFixture(); + status.json.addAll({ + 'station_connected': true, + 'station_ip': '192.168.4.1', + 'station_rssi': -57, + }); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(3)), + child: Directionality( + textDirection: direction, + child: child!, + ), + ), + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: StatusBar( + status: status, + message: 'Connected', + commandFailed: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byType(StatusBar)).height, + inInclusiveRange(48, 80), + ); + expect(find.byKey(const Key('status-signal')), findsOneWidget); + expect(tester.takeException(), isNull); + } + } + } + }, + ); +} diff --git a/mobile/test/status_bar_layout_test.dart b/mobile/test/status_bar_layout_test.dart index 8cb54b93..c57a584f 100644 --- a/mobile/test/status_bar_layout_test.dart +++ b/mobile/test/status_bar_layout_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pakarcontroller/main.dart'; import 'package:pakarcontroller/models.dart'; @@ -47,6 +48,47 @@ Future pumpFooter( } void main() { + testWidgets( + 'Phone footer truncates long feedback without losing its full accessible details', + (tester) async { + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const message = + 'The device rejected the command because its configuration changed while the previous preview was active.'; + for (final width in [320.0, 390.0]) { + await pumpFooter( + tester, + width: width, + failed: true, + message: message, + status: peerFixture(hostname: 'ledcontroller-with-a-long-hostname'), + onConnection: () {}, + ); + final text = find.descendant( + of: find.byKey(const Key('connection-status-area')), + matching: find.text(message), + ); + expect(tester.widget(text).maxLines, 1); + expect(tester.widget(text).overflow, TextOverflow.ellipsis); + expect( + tester.renderObject(text).didExceedMaxLines, + true, + ); + expect(find.byTooltip(message), findsOneWidget); + expect(tester.getSize(find.byType(StatusBar)).height, 48); + expect( + tester.getRect(find.byKey(const Key('command-status-details'))).right, + lessThanOrEqualTo(width), + ); + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const Key('command-status-details'))); + await tester.pumpAndSettle(); + expect(find.widgetWithText(SelectableText, message), findsOneWidget); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + } + }, + ); testWidgets( 'Entire connection status icon and text open connection, errors stay inspectable', (tester) async { diff --git a/mobile/test/table_viewport_test.dart b/mobile/test/table_viewport_test.dart index aebd0b38..602d10d3 100644 --- a/mobile/test/table_viewport_test.dart +++ b/mobile/test/table_viewport_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pakarcontroller/table_viewport.dart'; +import 'dart:math' as math; + void main() { testWidgets('Phone retains its compact explicit zoom controls', ( tester, @@ -80,10 +82,17 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); expect(view.zoom, greaterThan(1)); - expect( - (tester.getRect(last).center - fitted.center).distance, - lessThan(1), - ); + // Preserve the focal point until the no-empty-margin boundary clamps it. + // At wide fit the table is height-limited, so anchoring the far-right + // cell exactly would expose blank space beyond the table's right edge. + final fit = math.min(width / 960, 304 / 228); + final limit = math.max(0.0, (960 * fit * view.zoom - width) / 2); + final anchor = fitted.center.dx - width / 2; + final expectedX = + width / 2 + + anchor * view.zoom + + (anchor * (1 - view.zoom)).clamp(-limit, limit); + expect(tester.getRect(last).center.dx, closeTo(expectedX, 1)); await tester.tap(last); expect(taps, 2); await tester.tap(find.text('Fit')); @@ -176,10 +185,8 @@ void main() { final detector = tester.widget( find.byKey(const Key('table-viewport-gestures')), ); - final factory = - detector.gestures[ScaleGestureRecognizer]! - as GestureRecognizerFactoryWithHandlers; - final recognizer = factory.constructor(); + final factory = detector.gestures.values.single; + final recognizer = factory.constructor() as ScaleGestureRecognizer; factory.initializer(recognizer); final center = tester .getSize(find.byKey(const Key('table-viewport-gestures'))) @@ -212,8 +219,11 @@ void main() { ), ); expect(view.zoom, closeTo(.8, .000001)); - expect(view.pan.dx, closeTo(20, .000001)); - expect(view.pan.dy, closeTo(20, .000001)); + expect( + view.pan, + Offset.zero, + reason: 'A table smaller than its viewport is centered, never lost', + ); recognizer.onEnd!(ScaleEndDetails()); final pan = view.pan; recognizer.onUpdate!( @@ -273,4 +283,94 @@ void main() { view.dispose(); }, ); + testWidgets( + 'Nested page preserves holds and owns two-finger zoom with bounded snap', + (tester) async { + final view = TableViewportController(); + final scroll = ScrollController(); + var holds = 0, taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + controller: scroll, + child: Column( + children: [ + TableViewport( + controller: view, + child: GestureDetector( + onTap: () => taps++, + onLongPress: () => holds++, + child: const ColoredBox(color: Colors.blue), + ), + ), + const SizedBox(height: 1500), + ], + ), + ), + ), + ), + ); + final center = tester.getCenter( + find.byKey(const Key('table-viewport-gestures')), + ); + // Losing the arena to the enclosing page retires that pointer. A later + // single-finger hold must not be mistaken for a two-finger gesture. + await tester.dragFrom(center, const Offset(0, -60)); + await tester.pumpAndSettle(); + expect(scroll.offset, greaterThan(0)); + scroll.jumpTo(0); + await tester.pump(); + await tester.longPressAt(center); + expect(holds, 1); + expect(taps, 0); + final a = await tester.startGesture( + center - const Offset(70, 0), + pointer: 1, + ); + final b = await tester.startGesture( + center + const Offset(70, 0), + pointer: 2, + ); + await a.moveBy(const Offset(-90, -40)); + await b.moveBy(const Offset(90, 40)); + await tester.pump(); + await a.moveBy(const Offset(-40, -30)); + await b.moveBy(const Offset(40, 30)); + await tester.pump(); + await a.up(); + await b.up(); + await tester.pumpAndSettle(); + expect(view.zoom, greaterThan(1)); + expect(scroll.offset, 0); + expect(taps, 0); + await tester.dragFrom(center, const Offset(-2000, -2000)); + await tester.pumpAndSettle(); + // For the default 800x600 viewport, fit = 2/3 (height-limited). + final viewport = tester.getSize( + find.byKey(const Key('table-viewport-gestures')), + ); + final fit = (viewport.width / 960).clamp(0, viewport.height / 228); + expect( + view.pan.dx.abs(), + lessThanOrEqualTo( + (960 * fit * view.zoom - viewport.width).clamp(0, double.infinity) / + 2 + + .01, + ), + ); + expect( + view.pan.dy.abs(), + lessThanOrEqualTo( + (228 * fit * view.zoom - viewport.height).clamp(0, double.infinity) / + 2 + + .01, + ), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + view.dispose(); + scroll.dispose(); + }, + ); } diff --git a/tests/async_http_test.cpp b/tests/async_http_test.cpp index 7801de48..cd23167a 100644 --- a/tests/async_http_test.cpp +++ b/tests/async_http_test.cpp @@ -1,4 +1,5 @@ #include "AsyncHttpService.h" +#include "StaticAssetService.h" #include #include #include @@ -219,6 +220,38 @@ int main() { auto head=std::make_shared();head->requestMethod=HTTP_HEAD; server.native().handlers.at("/head")(head.get());server.service();drain(head,""); assert(head->connection.sentWire.find("Content-Length: 15\r\n")!=String::npos); + { + using namespace PakarStatic; + assert(accepts(nullptr,"gzip")&&!accepts("","gzip")); + assert(accepts("br, GZip; q=0.125","gzip")); + assert(!accepts("gzip;q=0, *;q=1","gzip")); + assert(!accepts("gzip;q=garbage","gzip")&&!accepts("gzip;q=1.1","gzip")); + assert(accepts("*;q=0.2","gzip")&&!accepts("br","gzip")); + assert(accepts("","identity")&&accepts("gzip","identity")); + assert(!accepts("*;q=0","identity")&&accepts("*;q=0, identity;q=1","identity")); + assert(notModified("W/\"other\", W/\"abc\"","\"abc\"")); + assert(notModified("*","\"abc\"")&&!notModified("\"abcd\"","\"abc\"")); + assert(!notModified("\"abc\"garbage","\"abc\"")); + auto asset=[&](int method,const char *enc,const char *tag,int status,const String &expected){ + auto r=std::make_shared();r->requestMethod=method; + if(enc)r->headers["Accept-Encoding"].text=enc; + if(tag)r->headers["If-None-Match"].text=tag; + r->connection.addLimits={1,0,3,1,0,13};r->connection.sendResults={false,true}; + {AsyncSdkFixture::Actor actor;serveStaticAsset(r.get(),"text/html",compressed.data(),compressed.size(),"\"abc\"",true);} + drain(r,expected);assert(r->status==status); + assert(r->responseHeaders.at("Vary")=="Accept-Encoding"); + assert(r->responseHeaders.at("X-Content-Type-Options")=="nosniff"); + if(status==304)assert(r->connection.sentWire.find("Content-Length:")==String::npos); + if(status==200)assert(r->connection.sentWire.find("Content-Length: 66056\r\n")!=String::npos); + if(status==406)assert(!r->responseHeaders.count("Content-Encoding")&&!r->responseHeaders.count("ETag")); + }; + asset(HTTP_GET,"gzip",nullptr,200,expectedCompressed); + asset(HTTP_HEAD,"gzip",nullptr,200,""); + asset(HTTP_GET,"gzip","W/\"abc\"",304,""); + asset(HTTP_GET,"identity","\"abc\"",406,""); + asset(HTTP_GET,"gzip;q=0",nullptr,406,""); + puts("Static assets: negotiation, validators, HEAD/304/406 and binary short-write/ACK integrity passed."); + } auto midway=std::make_shared();midway->connection.windowCapacity=127; server.native().handlers.at("/flash")(midway.get());server.service();midway->connection.poll(); assert(!midway->getResponse()->_finished());const auto midwayWire=midway->connection.wire; diff --git a/tests/avr_rx_script_test.ps1 b/tests/avr_rx_script_test.ps1 index 31a11077..a170de17 100644 --- a/tests/avr_rx_script_test.ps1 +++ b/tests/avr_rx_script_test.ps1 @@ -9,14 +9,19 @@ foreach($taskFunction in $taskAst.FindAll({param($node)$node -is [Management.Aut . ([ScriptBlock]::Create($taskFunction.Extent.Text)) } function Assert([bool]$Condition,[string]$Message){if(-not $Condition){throw $Message}} -function Must-Fail([scriptblock]$Action,[string]$Reason){ - $failed=$false;try{& $Action}catch{$failed=$true} +function Must-Fail([scriptblock]$Action,[string]$Reason,[string]$Expected=''){ + $failed=$false;try{& $Action}catch{ + $failed=$true + if($Expected){Assert ($_.Exception.Message -match $Expected) "Wrong failure phase: $($_.Exception.Message)"} + } Assert $failed $Reason } class AvrRxFixturePort { [Collections.Generic.Queue[int]]$Input=[Collections.Generic.Queue[int]]::new() [Collections.Generic.List[byte[]]]$Writes=[Collections.Generic.List[byte[]]]::new() - [int]ReadByte(){if(-not $this.Input.Count){throw [TimeoutException]::new()};return $this.Input.Dequeue()} + # Empty input is deterministic transport loss, not a CPU-speed benchmark. + # The shared wire fixture separately exercises real deadline expiry. + [int]ReadByte(){if(-not $this.Input.Count){return -1};return $this.Input.Dequeue()} [void]Write([byte[]]$Bytes,[int]$Offset,[int]$Length){ [byte[]]$copy=[byte[]]::new($Length);[Array]::Copy($Bytes,$Offset,$copy,0,$Length) $this.Writes.Add($copy) @@ -84,7 +89,7 @@ Assert ($port.Writes.Count -eq 10) '128-byte capture did not use exactly four bo $port=[AvrRxFixturePort]::new() Queue-Reply $port 0x81 1 ([byte[]]@(0,2,0,0x20,80,9,0,2,7)) [byte[]]$status=[byte[]]::new(42);$status[41]=1;Queue-Reply $port 0x90 2 $status -Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 10 {param($ms)}} 'Ordinary AVR link was not required paused' +Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 1000 {param($ms)}} 'Ordinary AVR link was not required paused' Assert ($port.Writes.Count -eq 2) 'Capture mutation occurred before pause ACK' # Unknown/old status is not a paused acknowledgement either. $status=[byte[]]::new(47);$status[2]=0x90;$status[4]=41 @@ -94,7 +99,7 @@ $port=[AvrRxFixturePort]::new() Queue-Reply $port 0x81 1 ([byte[]]@(0,2,0,0x20,80,9,0,2,7)) [byte[]]$status=[byte[]]::new(42);Queue-Reply $port 0x90 2 $status Queue-Reply $port 0xb4 3 (Snapshot-Payload 0 $false 0 255) -Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 10 {param($ms)} 3>$null} 'Missing start acknowledgement accepted' +Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 1000 {param($ms)} 3>$null} 'Missing start acknowledgement accepted' 'USB connection closed' $writes=@(Decode-Writes $port) Assert (@($writes | Where-Object {$_.Opcode -eq 0x5b -and $_.Action -eq 1}).Count -eq 1) 'Uncertain start retried' Assert (@($writes | Where-Object {$_.Opcode -eq 0x5b -and $_.Action -eq 2}).Count -eq 1) 'Cleanup stop missing or retried' @@ -105,7 +110,7 @@ Queue-Reply $port 0x81 1 ([byte[]]@(0,2,0,0x20,80,9,0,2,7)) Queue-Reply $port 0xb4 3 (Snapshot-Payload 0 $false 0 255) Queue-Reply $port 0xb4 4 (Snapshot-Payload 1 $true) Queue-Reply $port 0xb4 5 (Snapshot-Payload 0 $true) -Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 10 {param($ms)}} 'Lost stop acknowledgement accepted' +Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 1000 {param($ms)}} 'Lost stop acknowledgement accepted' 'USB connection closed' $writes=@(Decode-Writes $port) Assert (@($writes | Where-Object {$_.Opcode -eq 0x5b -and $_.Action -eq 2}).Count -eq 1) 'Uncertain explicit stop was retried' foreach($oe in @(1,255)){ @@ -114,7 +119,7 @@ foreach($oe in @(1,255)){ [byte[]]$status=[byte[]]::new(42);Queue-Reply $port 0x90 2 $status [byte[]]$unsafe=Snapshot-Payload 0 $false 0 255;$unsafe[26]=$oe Queue-Reply $port 0xb4 3 $unsafe - Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 10 {param($ms)}} 'Unsafe/unknown RESET output ownership allowed capture' + Must-Fail {Invoke-PakarAvrRxCapture $port 38400 100 1000 {param($ms)}} 'Unsafe/unknown RESET output ownership allowed capture' Assert ($port.Writes.Count -eq 3) 'Capture started before RESET ownership acknowledgement' } # Snapshot length, action, error, counter and pad bounds stay strict. diff --git a/tests/basic_cells_test.mjs b/tests/basic_cells_test.mjs index 3d186c79..07b76589 100644 --- a/tests/basic_cells_test.mjs +++ b/tests/basic_cells_test.mjs @@ -7,7 +7,9 @@ const peer=readFileSync(new URL('../assets/web/peer-http.js',import.meta.url),'u const html=readFileSync(new URL('../assets/web/basic.html',import.meta.url),'utf8'); const firmware=readFileSync(new URL('../firmware/esp/application.ino',import.meta.url),'utf8'); assert.match(firmware,/server\.native\(\)\.on\(AsyncURIMatcher::exact\("\/basic"\)/,'Immutable page bypasses the hardware queue'); -assert.match(firmware,/request->send\(200, "text\/html; charset=utf-8",\s*reinterpret_cast\(kBasicUi\), sizeof\(kBasicUi\) - 1\)/,'Use bounded flash response, not ESP32 char* send_P heap overload'); +assert.match(firmware,/AsyncURIMatcher::exact\("\/basic"\), HTTP_GET\|HTTP_HEAD,[\s\S]*?serveStaticAsset\(request,"text\/html; charset=utf-8",kBasicUi,sizeof\(kBasicUi\)-1,kStaticBasicEtag\)/,'Basic page must use the shared bounded asset response with HEAD and its own validator'); +const assetService=readFileSync(new URL('../firmware/esp/src/StaticAssetService.h',import.meta.url),'utf8'); +assert.match(assetService,/new PakarHttpDetail::SnapshotResponse\(status,type,\s*status==200\?static_cast\(bytes\):nullptr,status==200\?length:0,\{\}\)/,'Shared response retains explicit byte length, not the char* send_P heap overload'); const {createBasicCells,createPeerHttp}=vm.runInNewContext(peer+'\n'+js+'\n({createBasicCells,createPeerHttp})',{URLSearchParams,Headers,AbortController,setTimeout,clearTimeout,crypto:webcrypto}); function fixture(){ const status={version:'0.2.8',board_id:'esp32s3-aabbccddeeff',boot_id:'1'.repeat(32),http_peer_fencing:true,hostname:'ledboard',pixels:480, diff --git a/tests/ble_firmware_test.mjs b/tests/ble_firmware_test.mjs index 192a7708..418d3e10 100644 --- a/tests/ble_firmware_test.mjs +++ b/tests/ble_firmware_test.mjs @@ -96,13 +96,28 @@ assert(main.includes('body+=bleStatusJson()')); // Check actual service ownership, not their former adjacency to loop(). function networkServicesGuarded(source){ const loop=source.slice(source.indexOf('void loop() {')); - const block=/#if defined\(ARDUINO_ARCH_ESP32\) && PAKAR_NETWORK_ENABLED\r?\n([\s\S]*?)#endif/.exec(loop)?.[1]??''; - return ['serviceBle();','servicePeerRelay();'].every(call=>block.includes(call)&&loop.split(call).length===2); + const guards=[],seen=new Map(),calls=['serviceBle();','servicePeerRelay();']; + for(const line of loop.split(/\r?\n/)){ + const enter=/^\s*#if\s+(.+)$/.exec(line); + if(enter){guards.push(enter[1].trim());continue;} + if(/^\s*#ifn?def\b/.test(line)){guards.push('unsupported');continue;} + if(/^\s*#(?:else|elif)\b/.test(line)){guards[guards.length-1]='alternate';continue;} + if(/^\s*#endif\b/.test(line)){guards.pop();continue;} + for(const call of calls)if(line.includes(call)){ + seen.set(call,(seen.get(call)||0)+1); + const both='defined(ARDUINO_ARCH_ESP32) && PAKAR_NETWORK_ENABLED'; + const s3=guards.includes('defined(ARDUINO_ARCH_ESP32)')||guards.includes(both); + const network=guards.includes('PAKAR_NETWORK_ENABLED')||guards.includes(both); + if(!s3||!network)return false; + } + } + return calls.every(call=>seen.get(call)===1); } assert(networkServicesGuarded(main),'Offline builds exclude BLE and peer-relay service calls'); const loopOnly=main.slice(main.indexOf('void loop() {')); assert(!networkServicesGuarded(loopOnly.replace(' serviceBle();','')),'Missing service remains a failure'); assert(!networkServicesGuarded(loopOnly.replace(' serviceBle();','')+'\nserviceBle();'),'A service outside the guard remains a failure'); +assert(!networkServicesGuarded(loopOnly.replace('#if PAKAR_NETWORK_ENABLED\n servicePeerRelay();','#if !PAKAR_NETWORK_ENABLED\n servicePeerRelay();')),'Negated nested guard must fail'); assert(urboot.includes('serviceBleReadOnly();')); assert(wire.includes('packetLimit(mtu)-kHeader')); assert(wire.includes('static_cast(now-started_)>=60000U')); diff --git a/tests/cloud_management_test.cpp b/tests/cloud_management_test.cpp index afbf15ad..84ab273f 100644 --- a/tests/cloud_management_test.cpp +++ b/tests/cloud_management_test.cpp @@ -10,6 +10,7 @@ NetworkFixtureWiFi WiFi; struct RemoteFixture { bool active=false; bool busy()const{return active;} } remoteFirmware; bool otaActive=false,avrUploadOwner=false,programmingLatched=false; bool networkApplyPending=false,cloudApplyPending=false; +unsigned artifactReaders=0; uint32_t otaRebootAt=0,networkApplyAt=0,cloudApplyAt=0; struct CloudFixture { bool mqttConnected()const{return false;} @@ -133,6 +134,7 @@ int main() { for(bool* busy:{&otaActive,&avrUploadOwner,&programmingLatched,&networkApplyPending,&cloudApplyPending,&remoteFirmware.active}) { *busy=true;assert(!cloud("{\"telemetry_seconds\":19}",result));assert(!network("{\"dhcp\":true}",result));*busy=false; } + artifactReaders=1;assert(!cloud("{\"telemetry_seconds\":19}",result));assert(!network("{\"dhcp\":true}",result));artifactReaders=0; otaRebootAt=1;assert(!cloud("{\"telemetry_seconds\":19}",result));otaRebootAt=0; std::cout<<"Actual cloud/network shared configuration: ACK, exact types, redaction/escaping, candidate commit, failure preservation, pending fences, DHCP/static profile passed\n"; } diff --git a/tests/desktop_replacement_probe_test.mjs b/tests/desktop_replacement_probe_test.mjs new file mode 100644 index 00000000..a4b2fe51 --- /dev/null +++ b/tests/desktop_replacement_probe_test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; + +const source=readFileSync(new URL('../tools/replace-desktop.ps1',import.meta.url),'utf8'); +const owner=source.slice(source.indexOf('function OwnerStatus {'),source.indexOf('$task=Get-ScheduledTask')); +assert.match(owner,/owner\.ExecutablePath -ine \$ExecutablePath/); +assert.match(owner,/owner\.SessionId -ne \$session/); +assert.match(owner,/AssertHash \$ExecutablePath \$ExpectedOldHash/); +assert.match(owner,/AssertHash \$CandidatePath \$ExpectedNewHash[\s\S]*Invoke-PackageProbe \$CandidatePath '--app-status'/); +assert.match(owner,/status\.process_id -ne \$ExpectedProcessId/); +assert.match(owner,/preferences_dirty/); +assert.match(owner,/maintenance_pending/); +assert.doesNotMatch(source,/Stop-Process|taskkill/i); +console.log('Replacement source guards passed: larger candidate reader retains exact owner, hash, session and draft checks; no force-close fallback.'); diff --git a/tests/device_context_test.cpp b/tests/device_context_test.cpp index 5048dc17..b231486b 100644 --- a/tests/device_context_test.cpp +++ b/tests/device_context_test.cpp @@ -32,7 +32,7 @@ int main() { assert(!(features(true, 0, true, true, true) & (Display | Text | Buzzer))); if (PAKAR_TABLE_ENABLED) { assert((live & Table)); assert(bool(live & CellBatch)==bool(PAKAR_TABLE_SELECTION)); - assert((modes(live) & 8) == (PAKAR_SNAKE_ENABLED ? 8 : 0)); + assert((modes(live) & 8) == 0); // Hidden presentation, never a business/menu mode. assert(strcmp(name(14),"cell-batch")==0); assert((modes(live) & 4) == (PAKAR_FIRST_WINS_ENABLED ? 4 : 0)); assert(modes(live)==(1U<().size()==32); assert(observed["pixel_frame"]["pending"]==true); assert(observed["pixel_frame"]["revision"]==UINT32_MAX); assert(diagnosticReceiptStatus(raw,"null",result)); @@ -49,6 +54,14 @@ int main(int argc,char **argv){ // budget check against a read-only capture, not a live publication claim. assert(diagnosticReceiptStatus(String(captured),R"({"revision":4294967295,"brightness":255,"pending":false})",result)); printf("Captured status projection: %zu input bytes, %zu receipt status bytes (limit7168); fixture frame metadata, not a hardware test.\n",captured.size(),result.size()); + JsonDocument candidate;assert(!deserializeJson(candidate,captured)); + auto presentation=candidate["presentation"].to(); + for(const char *key:{"revision","saved_revision","lease_id","group_layout_revision"})presentation[key]=UINT32_MAX; + presentation["owner"]=std::string(32,'x');presentation["remaining_ms"]=10000; + for(const char *key:{"active","suspended","persistent","temporary","frame"})presentation[key]=true; + raw=String();serializeJson(candidate,raw); + assert(diagnosticReceiptStatus(raw,R"({"revision":4294967295,"brightness":255,"pending":false,"presentation_revision":4294967295})",result)); + printf("Captured status plus maximum host summary: %zu receipt status bytes (limit7168).\n",result.size()); } puts("USB diagnostic projection preserves authoritative fields, strips unrelated payloads and rejects malformed/oversized readback without truncation."); } diff --git a/tests/discovery_mdns_test.cpp b/tests/discovery_mdns_test.cpp index 65eb59e2..a5b283dc 100644 --- a/tests/discovery_mdns_test.cpp +++ b/tests/discovery_mdns_test.cpp @@ -41,6 +41,11 @@ int main() { assert(noShell.registrations==14); Responder moved;assert(PakarS3::startDiscoveryMdns(moved,"ledboard","PakarController","pakarcontroller","0.2.7",7777,2323)); assert(moved.entries.back()=="telnet.tcp:2323"); + Responder ssh;assert(PakarS3::startDiscoveryMdns(ssh,"keys","Pakar Keys","pakarcontroller","0.2.8",7777,23,22)); + assert(ssh.entries.back()=="ssh.tcp:22"&&ssh.registrations==16); + Responder failedSsh;failedSsh.failAt=16; + assert(!PakarS3::startDiscoveryMdns(failedSsh,"keys","Pakar Keys","pakarcontroller","0.2.8",7777,23,22)); + assert(!failedSsh.initialized&&failedSsh.entries.empty()); Responder partial;partial.failBegin=true; assert(!start(partial)&&!partial.initialized&&partial.stops==1&&partial.registrations==0); partial.failBegin=false;assert(start(partial)); diff --git a/tests/doc_links_test.mjs b/tests/doc_links_test.mjs new file mode 100644 index 00000000..dfc24d67 --- /dev/null +++ b/tests/doc_links_test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import {mkdirSync, writeFileSync} from 'node:fs'; +import {resolve} from 'node:path'; +import {checkGuides, localLinkReference, markdownAnchors} from '../tools/check-doc-links.mjs'; +const root = resolve('build/tests/doc-links'); +mkdirSync(root, {recursive: true}); +writeFileSync(resolve(root, 'guide.md'), '# Hello\n# Hello\n## **سلام**\n\n````md\n```\n# Not a heading\n````\n'); +assert.deepEqual([...markdownAnchors('# One\n# One\n# One-1\n# One')], ['one','one-1','one-1-1','one-2']); +assert.equal(localLinkReference('https://example.invalid/a'), null); +assert.equal(localLinkReference('//example.invalid/a'), null); +assert.deepEqual(localLinkReference(''), {target:'guide.md',fragment:'hello-1'}); +writeFileSync(resolve(root, 'README.md'), '[ok](guide.md#hello-1) [rtl](guide.md#سلام) x\n```\n[example](missing.md)\n```'); +assert.deepEqual(checkGuides(root, ['README.md']), []); +for (const link of ['guide.md#not-a-heading','missing.md','../outside.md','guide.md#%zz']) { + writeFileSync(resolve(root, 'README.md'), `[bad](${link})`); + assert.equal(checkGuides(root, ['README.md']).length, 1, link); +} +assert.equal(checkGuides(root, ['absent.md']).length, 1); +assert.equal(checkGuides(root, ['.']).length, 1, 'Directory is not a guide'); +writeFileSync(resolve(root, 'README.md'), '[directory](.)'); +assert.equal(checkGuides(root, ['README.md']).length, 1, 'Directory is not a linked file'); +writeFileSync(resolve(root, 'README.md'), '[valid](guide.md#hello) [again](guide.md#hello-1)'); +assert.deepEqual(checkGuides(root, ['README.md']), [], 'Repeated links reuse one descriptor snapshot'); +console.log('Donor-adapted documentation checks: local links, headings, fences, Unicode, HTML, boundaries and malformed URLs passed.'); diff --git a/tests/ethernet_management_test.cpp b/tests/ethernet_management_test.cpp index ec5d617f..6547adab 100644 --- a/tests/ethernet_management_test.cpp +++ b/tests/ethernet_management_test.cpp @@ -12,6 +12,7 @@ EthernetConfigStore ethernetConfig; struct RemoteFixture {bool active=false;bool busy()const{return active;}} remoteFirmware; bool otaActive=false,avrUploadOwner=false,programmingLatched=false; bool networkApplyPending=false,cloudApplyPending=false,ethernetApplyOnly=false; +unsigned artifactReaders=0; bool apStarted=true; uint32_t otaRebootAt=0,networkApplyAt=0,cloudApplyAt=0; struct CloudFixture { @@ -123,6 +124,9 @@ int main() { *busy=true;assert(!update("{\"dhcp\":false}",result));*busy=false; } otaRebootAt=1;assert(!update("{\"dhcp\":false}",result));otaRebootAt=0; + const auto beforeReadLease=filesystem.files.at("/ethernet.bin")->bytes; + artifactReaders=1;assert(!update("{\"dhcp\":false}",result));artifactReaders=0; + assert(filesystem.files.at("/ethernet.bin")->bytes==beforeReadLease); assert(!ethernetApplyOnly); assert(update("{\"enabled\":false,\"lldp_enabled\":false,\"lldp_receive\":true}",result));applyReceiptConsumed(); auto services=snapshot(ethernetConfigurationJson()); diff --git a/tests/file_stubs/FS.h b/tests/file_stubs/FS.h new file mode 100644 index 00000000..b036e3b9 --- /dev/null +++ b/tests/file_stubs/FS.h @@ -0,0 +1,31 @@ +#pragma once +#include "../access_stubs/Arduino.h" +#include +#include +class File { + public: + std::shared_ptr data;size_t position=0;bool failWrite=false; + explicit operator bool()const{return bool(data);} + size_t size()const{return data?data->size():0;} + int read(){return data&&positionsize()?uint8_t((*data)[position++]):-1;} + size_t read(uint8_t *out,size_t n){size_t i=0;for(;i(out),n);} + size_t write(const uint8_t *bytes,size_t n){if(!data||failWrite)return 0;if(data->size()resize(position+n);memcpy(data->data()+position,bytes,n);position+=n;return n;} + size_t print(const String &s){return write(reinterpret_cast(s.c_str()),s.size());} + bool seek(size_t offset){if(!data||offset>data->size())return false;position=offset;return true;} + String readString(){return data?String(*data):String();} + void flush(){} + void close(){data.reset();} +}; +namespace fs { +class FS { + public: + std::map> files; + unsigned writes=0;bool failWrite=false,failRename=false; + bool exists(const char *path)const{return files.count(path);} + bool mkdir(const char *path){files[path]=std::make_shared();return true;} + File open(const char *path,const char *mode){if(mode[0]=='w'){++writes;files[path]=std::make_shared();}return {exists(path)?files[path]:nullptr,0,failWrite};} + bool rename(const char *from,const char *to){if(failRename||!exists(from))return false;files[to]=files[from];files.erase(from);return true;} + bool remove(const char *path){return files.erase(path)>0;} +}; +} diff --git a/tests/file_transfer_client_test.mjs b/tests/file_transfer_client_test.mjs new file mode 100644 index 00000000..be85b4c7 --- /dev/null +++ b/tests/file_transfer_client_test.mjs @@ -0,0 +1,18 @@ +import {spawnSync} from 'node:child_process'; +import {mkdirSync,writeFileSync,readFileSync} from 'node:fs'; +import {resolve} from 'node:path'; +import assert from 'node:assert/strict'; +const executable=resolve('build/native/file_transfer_test.exe'); +const root=resolve('build/native/transfer-client-fixture');mkdirSync(root,{recursive:true}); +for(const [name,size] of [['test.bin',513],['empty.bin',0]]){ + const file=resolve(root,name);writeFileSync(file,Buffer.from(Array.from({length:size},(_,i)=>i&255))); + const result=spawnSync('scp',['-O','-S',executable,file,`fixture:/user/${name}`],{encoding:'utf8',timeout:15000}); + assert.equal(result.status,0,result.error?.message||result.stderr); +} +const bytes=Buffer.from(Array.from({length:65539},(_,i)=>i&255)); +const source=resolve(root,'sftp-source.bin'),target=resolve(root,'sftp-result.bin'),batch=resolve(root,'batch.txt');writeFileSync(source,bytes); +const quote=value=>`"${value.replaceAll('\\','/')}"`; +writeFileSync(batch,`put ${quote(source)} /user/roundtrip.bin\nls -l /user\nget /user/roundtrip.bin ${quote(target)}\nbye\n`); +const result=spawnSync('sftp',['-D',`${quote(executable)} --sftp`,'-b',batch],{encoding:'utf8',timeout:30000}); +assert.equal(result.status,0,result.error?.message||result.stderr);assert.deepEqual(readFileSync(target),bytes); +console.log('Real OpenSSH clients: SCP binary/empty uploads and default-buffer SFTP 65KiB put/list/get byte roundtrip passed over production protocol stdio fixtures. No encrypted SSH or ESP socket/hardware acceptance.'); diff --git a/tests/file_transfer_test.cpp b/tests/file_transfer_test.cpp new file mode 100644 index 00000000..ce9834d4 --- /dev/null +++ b/tests/file_transfer_test.cpp @@ -0,0 +1,66 @@ +#include "UserFileStore.h" +#include "TransferFiles.h" +#include "../firmware/esp32s3/src/ScpUpload.h" +#include "../firmware/esp32s3/src/SftpSession.h" +#include "../firmware/esp32s3/src/FtpSession.h" +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#endif +struct Digest {uint32_t value=0;void begin(){value=0;}void add(const void* bytes,size_t n){auto p=static_cast(bytes);for(size_t i=0;i store;uint32_t sequence=1,security=1; +String request(JsonObjectConst body,bool stream){return store.request(body["action"]|"",body,1,security,++sequence,stream);} +using Files=TransferFiles; +void put(std::vector& p,uint32_t n){p.push_back(n>>24);p.push_back(n>>16);p.push_back(n>>8);p.push_back(n);} +void text(std::vector& p,const std::string& s){put(p,s.size());p.insert(p.end(),s.begin(),s.end());} +std::vector packet(uint8_t type,uint32_t id){std::vector p{0,0,0,0,type};put(p,id);return p;} +void seal(std::vector& p){auto n=p.size()-4;p[0]=n>>24;p[1]=n>>16;p[2]=n>>8;p[3]=n;} +int main(int argc,char** argv){ + store.begin(volume,true);Files files(request); + auto wire=[](const uint8_t* p,size_t n){const bool ok=fwrite(p,1,n,stdout)==n;fflush(stdout);return ok;}; + if(argc>1){ +#ifdef _WIN32 + _setmode(_fileno(stdin),_O_BINARY);_setmode(_fileno(stdout),_O_BINARY); +#endif + if(std::string(argv[1])=="--sftp"){ + PakarS3::SftpSession session(files);assert(session.ready()); + int c;while((c=getchar())!=EOF){while(!session.input(uint8_t(c))){session.service(wire);if(session.failed())return 2;}for(unsigned i=0;i<300;++i)session.service(wire);if(session.failed())return 3;}files.cancel();return 0; + } + for(int i=1;i(),1,security,88));assert(r["accepted"]!=true); // JSON cannot grant streaming authority. + PakarS3::SftpSession sftp(files);assert(sftp.ready());std::vector output; + bool blocked=false;auto emit=[&](const uint8_t* p,size_t n){if(blocked)return false;output.insert(output.end(),p,p+n);return true;}; + auto send=[&](std::vector p){seal(p);output.clear();for(auto c:p){unsigned budget=0;while(!sftp.input(c)){sftp.service(emit);assert(++budget<1000);}}for(unsigned i=0;i<400;++i)sftp.service(emit);assert(!sftp.failed());}; + send(packet(1,3));assert(output[4]==2); // VERSION 3. + auto open=packet(3,1);text(open,"/user/big.bin");put(open,26);put(open,0);send(open);assert(output[4]==102); + auto write=packet(6,2);text(write,"f");put(write,0);put(write,0);put(write,32768);for(unsigned i=0;i<32768;++i)write.push_back(uint8_t(i));send(write);assert(output[4]==101&&output[12]==0); + auto attr=packet(10,3);text(attr,"f");put(attr,8);put(attr,1);put(attr,1700000000);send(attr);assert(output[12]==0); + auto close=packet(4,4);text(close,"f");send(close);assert(output[12]==0);assert(files.stat("big.bin",info)&&info.size==32768&&info.modified==1700000000000ULL); + auto readOpen=packet(3,5);text(readOpen,"/user/big.bin");put(readOpen,1);put(readOpen,0);send(readOpen);assert(output[4]==102); + auto read=packet(5,6);text(read,"f");put(read,0);put(read,256);put(read,32768);send(read);assert(output[4]==103&&output.size()==525);for(unsigned i=0;i<512;++i)assert(output[13+i]==uint8_t(256+i)); + send(close);assert(output[12]==0); + auto denied=packet(3,7);text(denied,"/access.bin");put(denied,26);put(denied,0);send(denied);assert(output[4]==101&&output[12]!=0); + auto realpath=packet(16,8);text(realpath,".");seal(realpath);output.clear();for(auto c:realpath)assert(sftp.input(c));blocked=true;sftp.service(emit);assert(output.empty()&&!sftp.input(0));blocked=false;for(unsigned i=0;i<30;++i)sftp.service(emit);assert(output[4]==104); + for(const char* header:{"C0644 -1 bad.bin\n","C0644 4294967296 bad.bin\n","C0644 131073 bad.bin\n","C0644 0 other.bin\n"}){ + PakarS3::ScpUpload bad;bad.start("bad.bin");bad.service(files,emit);for(const char* c=header;*c;++c)bad.input(*c,files);assert(bad.phase()==PakarS3::ScpUpload::Failed&&!files.opened()); + } + PakarS3::FtpSession ftp(files);auto command=[&](const char* value){char line[256];snprintf(line,sizeof(line),"%s",value);ftp.command(line,"user","password123");return std::string(ftp.response());}; + assert(command("STOR hi.bin").find("530")==0);assert(command("USER user").find("331")==0);assert(command("PASS wrong").find("530")==0);assert(command("PASS password123").find("230")==0); + assert(command("TYPE A").find("504")==0);assert(command("PORT 1,2,3,4,5,6").find("502")==0);assert(command("STOR ../private").find("550")==0); + command("EPSV");assert(ftp.action==decltype(ftp)::Passive&&ftp.extended);command("STOR /user/a.bin");assert(ftp.action==decltype(ftp)::Store);assert(command("SIZE big.bin")=="213 32768\r\n"); + puts("Actual managed-store transfer adapters: atomic/interrupted upload, SHA publication, private isolation, security revocation, full 32KiB SFTP writes/short reads/timestamps, FTP auth/passive-only policy passed (FS/network doubles)."); +} diff --git a/tests/filesystem_telemetry_test.mjs b/tests/filesystem_telemetry_test.mjs index ad2417f3..90aeb10b 100644 --- a/tests/filesystem_telemetry_test.mjs +++ b/tests/filesystem_telemetry_test.mjs @@ -17,6 +17,7 @@ function cachedTelemetry(source,sketch) { assert.doesNotMatch(sketch.slice(loop),/captureBootFilesystemTelemetry\(|refreshFilesystemTelemetry\(/,'No automatic runtime filesystem refresh'); } cachedTelemetry(routes,main); +assert.match(routes,/add\("board",buildStatusJson\(true\)\)/,'Aggregate telemetry must use the shared compact snapshot; full detail remains at /api/status'); assert.throws(()=>cachedTelemetry(routes.replace('String buildTelemetryJson() {','String buildTelemetryJson() { LittleFS.usedBytes();'),main)); assert.throws(()=>cachedTelemetry(routes,main.replace('captureBootFilesystemTelemetry(filesystemMounted);',''))); // Never substitute diagnostic cache data into irreversible storage admission. diff --git a/tests/ftp_binding_test.cpp b/tests/ftp_binding_test.cpp new file mode 100644 index 00000000..44584a95 --- /dev/null +++ b/tests/ftp_binding_test.cpp @@ -0,0 +1,62 @@ +#define PAKAR_FTP_ENABLED 1 +#include "UserFileStore.h" +#include +#include +#include +#include +#include +#include +#include +#include "../tests/ssh_stubs/Preferences.h" +#include "../firmware/esp32s3/src/FtpSession.h" +#include +struct Digest {uint32_t value=0;void begin(){value=0;}void add(const void* b,size_t n){auto p=static_cast(b);for(size_t i=0;i in;std::string out;bool eof=false,reset=false;unsigned ip=1;}; +std::map sockets;std::map> accepts; +int send(int fd,const void* p,size_t n,int){if(!sockets.count(fd)){errno=EBADF;return -1;}n=std::min(n,7);sockets[fd].out.append(static_cast(p),n);return int(n);} +int recv(int fd,void* p,size_t n,int){if(!sockets.count(fd)){errno=EBADF;return -1;}auto&s=sockets[fd];if(s.reset){errno=ECONNRESET;return -1;}n=std::min(n,s.in.size());if(!n){if(s.eof)return 0;errno=EAGAIN;return -1;}for(size_t i=0;i(p)[i]=s.in.front();s.in.pop_front();}return int(n);} +struct IPAddress {unsigned value=1;unsigned operator[](unsigned i)const{return i==3?value:192;}bool operator==(const IPAddress&b)const{return value==b.value;}}; +class WiFiClient {int id_=-1;public:WiFiClient()=default;explicit WiFiClient(int n):id_(n){};int fd()const{return id_;}explicit operator bool()const{return id_>=0;}void stop(){id_=-1;}void setNoDelay(bool){}IPAddress localIP()const{return {};}IPAddress remoteIP()const{return {sockets[id_].ip};}}; +class WiFiServer {unsigned port_=0;bool open_=false;public:void begin(unsigned port){port_=port;open_=true;}void stop(){open_=false;}explicit operator bool()const{return open_;}WiFiClient accept(){if(!open_||accepts[port_].empty())return {};const int n=accepts[port_].front();accepts[port_].pop_front();return WiFiClient(n);}}; +static std::map> nvs; +bool Preferences::begin(const char*,bool){return true;}void Preferences::end(){}bool Preferences::isKey(const char*k){return nvs.count(k);}size_t Preferences::getBytesLength(const char*k){return nvs[k].size();} +size_t Preferences::getBytes(const char*k,void*p,size_t n){if(nvs[k].size()!=n)return 0;memcpy(p,nvs[k].data(),n);return n;} +size_t Preferences::putBytes(const char*k,const void*p,size_t n){nvs[k]={static_cast(p),static_cast(p)+n};return n;} +fs::FS volume;UserFileStore store;uint32_t token=0;unsigned artifactReaders=0;bool flashAllowed=true,maintenance=false; +struct Access {uint32_t revision=7;bool ready()const{return true;}uint32_t securityRevision()const{return revision;}const char* username()const{return "user";}const char* password()const{return "password123";}} accessConfig; +struct ShellSettings {uint16_t port()const{return 23;}}shellSettings; +namespace NetworkPath {bool ready(){return true;}} +bool apStarted=false;bool shellBusy(){return maintenance;}bool networkApplyPending=false,cloudApplyPending=false; +bool maintenanceApiIdle(){return !maintenance&&!artifactReaders;} +String maintenanceApiError(const char* text){return String("{\"accepted\":false,\"error\":\"")+text+"\"}";} +String managedUserFileRequest(JsonObjectConst q,bool stream){if(!flashAllowed)return maintenanceApiError("led_flash_guard_busy");return store.request(q["action"]|"",q,millis(),accessConfig.revision,++token,stream);} +namespace PakarShell {bool unsignedNumber(const char*,uint32_t,uint32_t&){return false;}} +namespace PakarConfiguration {enum {Shell};struct Changes {void saved(int){}};Changes& changes(){static Changes c;return c;}} +enum {HTTP_GET,HTTP_POST}; +struct Server {templatevoid on(const char*,int,F){}void send(int,const char*,const String&){}bool hasArg(const char*){return false;}String arg(const char*){return "";}}server; +bool authenticate(){return true;} +#include "FtpBinding.h" +void turn(unsigned n=1){for(unsigned i=0;i +#define MSG_DONTWAIT 1 +int send(int,const void*,size_t,int); +int recv(int,void*,size_t,int); diff --git a/tests/host_mini_apps_test.mjs b/tests/host_mini_apps_test.mjs new file mode 100644 index 00000000..c5a2432e --- /dev/null +++ b/tests/host_mini_apps_test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import {runMiniApp,snakeFrame,memoryFrame,cellAt} from '../examples/host-mini-apps.mjs'; +import {toolPolicy} from '../tools/peer-identity.mjs'; +const board='esp32s3-test',boot='1234567890abcdef1234567890abcdef'; +function fixture({unknown=false,existing=false,networkDelay=0}={}){ + let time=0,revision=0,owner='',lease=0;const writes=[],waits=[],writeTimes=[]; + const reply=value=>({status:200,remote:'192.168.4.1',body:Buffer.from(JSON.stringify(value))}); + const identity={board_id:board,boot_id:boot}; + const request=async(_origin,path,options)=>{ + time+=networkDelay; + if(path==='/api/status')return reply({...identity,version:toolPolicy.minimum_board, + http_peer_fencing:true,hostname:'ledcontroller',command_clock_ms:time, + capabilities:{hostname:'ledcontroller',acknowledged_features:['network','host-presentation']}}); + if(path==='/api/pixels/groups')return reply({...identity,group_count:80,revision:7}); + if(path==='/api/presentation')return reply({...identity,revision,owner,lease_id:lease, + active:existing||!!lease,persistent:false,temporary:!!lease}); + assert.equal(path,'/api/command');const packet=JSON.parse(options.form.packet);writes.push(packet);writeTimes.push(time); + assert.match(packet.request_id,/^[0-9a-f]{32}$/);assert.equal(packet.board_id,board);assert.equal(packet.boot_id,boot); + assert.equal(packet.lifetime,'temporary');assert.equal(packet.group_layout_revision,7); + assert.equal(packet.revision,revision);assert.equal(packet.expires_at_ms,time+8000-networkDelay*2); + ++revision;owner=packet.owner;lease=packet.action==='clear'?0:123; + if(unknown)throw Error('Simulated lost receipt'); + return reply({...identity,request_id:packet.request_id,accepted:true,presentation:{revision,lease_id:lease}}); + }; + return {request,writes,waits,writeTimes,now:()=>time,wait:async ms=>{time+=ms;waits.push(ms)}}; +} +const geometry=Array.from({length:80},(_,i)=>cellAt(i%20,Math.floor(i/20))); +assert.equal(new Set(geometry).size,80);assert.equal(Math.min(...geometry),1);assert.equal(Math.max(...geometry),80); +for(let i=0;i<80;++i){const rows=snakeFrame(i);assert.equal(rows.length,5);assert.equal(new Set(rows.map(r=>r.id)).size,5);} +assert.equal(memoryFrame().length,5);assert.equal(memoryFrame(true).length,0); +let f=fixture();await assert.rejects(runMiniApp({...f,log:()=>{}}),/Explicit --run/);assert.equal(f.writes.length,0); +f=fixture();await runMiniApp({...f,mode:'snake',steps:3,run:true,log:()=>{}}); +assert.equal(f.writes.length,4);assert.equal(f.writes.at(-1).action,'clear'); +f=fixture();await runMiniApp({...f,mode:'memory',run:true,log:()=>{}}); +const blank=f.writes.findIndex(p=>p.replace&&p.entries?.length===0); +assert.equal(f.writeTimes[blank]-f.writeTimes[0],10000); +assert.equal(f.writes.at(-1).action,'clear');assert(f.writes.slice(1,blank).every(p=>p.replace===false&&p.entries.length===0)); +f=fixture({networkDelay:400});await runMiniApp({...f,mode:'memory',run:true,log:()=>{}}); +const delayedBlank=f.writes.findIndex(p=>p.replace&&p.entries?.length===0); +assert(f.writeTimes[delayedBlank]-f.writeTimes[0]>=10000); +assert(f.writeTimes[delayedBlank]-f.writeTimes[0]<=11200); // Only final3x400ms read/read/write remains. +f=fixture({unknown:true});await assert.rejects(runMiniApp({...f,run:true,log:()=>{}}),/lost receipt/); +assert.equal(f.writes.length,1); // No blind release/replay after uncertain acceptance. +f=fixture({existing:true});await assert.rejects(runMiniApp({...f,run:true,log:()=>{}}),/Existing presentation/); +assert.equal(f.writes.length,0); +console.log('Host mini-app examples:80-cell geometry, ten-second memory phase, temporary-only fenced commands, explicit-run gate and unknown-outcome no-replay cleanup passed (mocked I/O).'); diff --git a/tests/host_presentation_routes_test.mjs b/tests/host_presentation_routes_test.mjs new file mode 100644 index 00000000..ade8483c --- /dev/null +++ b/tests/host_presentation_routes_test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const read=p=>readFileSync(new URL('../'+p,import.meta.url),'utf8'); +const esp=read('firmware/esp/application.ino'),avr=read('firmware/avr/avr.ino'); +const usb=read('firmware/esp/src/UsbApiService.h'),grid=read('firmware/esp/src/LedGrid.cpp'); +assert.match(esp,/server.on\("\/api\/presentation",HTTP_GET/); +assert.match(esp,/server.on\("\/api\/presentation",HTTP_POST,[\s\S]+?commandReply\(message\)/); +assert.match(usb,/path=="\/api\/presentation"[\s\S]+?commandReply\(command,physicalUsb\)/); +assert.match(esp,/command=="presentation"[\s\S]+?applyHostPresentation\(commandRequest\)/); +assert.match(esp,/command=="pixel-corrections-preview"[\s\S]+?applyCorrectionPreview\(settings/); +const loop=esp.slice(esp.indexOf('void loop() {')); +assert(loop.indexOf('hostPresentation.model.service')compose')=0); + assert(action.indexOf('buzzer.update(millis())')observe\(hostPresentation.model.revision,hostPresentation.model.lease\)/); +assert.match(esp,/entry->observationMatches\(pixelCorrections.activeRevision\(\),pixelCorrections.previewLease\(\)\)/); +assert.match(esp,/if\(!observed\)[\s\S]*?reply\["accepted"\]=false;reply\["original_accepted"\]=true/); +assert.match(esp,/reply\["acknowledged_mask"\]=request\["mask"\]/); +assert.match(read('libraries/BoardCore/src/CommandReceipts.h'),/avr_outcome_unknown/); +assert.match(usb,/if\(!path\.startsWith\("\/user\/"\)/); // Persistent presentation cannot bypass validated save. +console.log('Host presentation/common transport + immediate AVR scheduling source guards passed; not hardware latency evidence.'); diff --git a/tests/host_presentation_test.cpp b/tests/host_presentation_test.cpp new file mode 100644 index 00000000..891d1e99 --- /dev/null +++ b/tests/host_presentation_test.cpp @@ -0,0 +1,109 @@ +#include "HostPresentationStore.h" +#include "StateAppearanceSettings.h" +#include +#include +#include +#include +using Store=HostPresentationStore<4,24>; +using Layout=BoardCore::PixelGroupLayout<4,6,24>; +struct Rgb {uint8_t r,g,b;Rgb(uint8_t r=0,uint8_t g=0,uint8_t b=0):r(r),g(g),b(b){} + bool operator==(const Rgb&o)const{return r==o.r&&g==o.g&&b==o.b;}}; +static JsonDocument parse(const char *bytes){JsonDocument doc;assert(!deserializeJson(doc,bytes));return doc;} +int main(){ + fs::FS fs;Store store;Layout layout; + // Two production stores share LittleFS, not a filename or staging owner. + const std::string appearance=R"({"enabled":true,"pulse_ms":900,"states":{"won":{"rgb":1193046,"motion":1}}})"; + const std::string appearancePending="unrelated appearance staging bytes"; + fs.files["/presentation.json"]=std::make_shared(appearance); + fs.files["/presentation.pending"]=std::make_shared(appearancePending); + StateAppearanceSettings palette;assert(palette.load(fs)); + store.begin(fs,true,100); + assert(store.json().find("storage_error")==String::npos); + const std::string corrections=R"({"revision":31,"enabled":true,"rules":[{"index":4,"shift":-1,"enabled":true}]})"; + const std::string scene="opaque pre-existing scene bytes"; // Unrelated owners are never parsed or rewritten. + fs.files["/pixel-corrections.json"]=std::make_shared(corrections); + fs.files["/scene.bin"]=std::make_shared(scene); + auto request=parse(R"({"owner":"miniapp","revision":0,"group_layout_revision":0,"lifetime":"temporary","ttl_ms":250,"composite":"overlay","space":"cells","replace":true,"entries":[{"id":1,"rgb":65280}]})"); + pakarTestMillis=UINT32_MAX-100;assert(!store.apply(request.as(),layout,millis())); + assert(store.model.lease==101&&fs.writes==0&&store.model.revision==1); + Rgb pixels[24];for(auto&p:pixels)p=Rgb(255,0,0);const uint8_t mapping[]={4,3,2,1}; + store.model.compose(pixels,layout,mapping,millis());assert(pixels[18]==Rgb(0,255,0)&&pixels[0]==Rgb(255,0,0)); + assert(store.apply(request.as(),layout,millis())); // Duplicate cannot silently reacquire. + request["revision"]=1;request["lease_id"]=101;request["owner"]="other"; + assert(store.apply(request.as(),layout,millis()));assert(store.model.revision==1); + request["owner"]="miniapp";request["entries"][0]["rgb"]=0x1234567; + assert(store.apply(request.as(),layout,millis()));assert(store.model.revision==1); + pakarTestMillis=149;assert(store.model.service(millis()));assert(!store.model.active()&&fs.writes==0); + request["revision"]=2;request["lease_id"]=0;request["entries"][0]["rgb"]=0x00ff00;request["lifetime"]="persistent"; + assert(!store.apply(request.as(),layout,millis()));assert(fs.writes==1&&store.model.savedRevision==3); + Store reboot;reboot.begin(fs,true,200);assert(reboot.model.saved.enabled&&reboot.model.revision==3); + request["revision"]=3;assert(!reboot.apply(request.as(),layout,millis())); + assert(fs.writes==1&&reboot.model.savedRevision==3); // Explicit identical save does not wear flash. + request["revision"]=4;request["entries"][0]["rgb"]=0xff0000;fs.failRename=true; + assert(reboot.apply(request.as(),layout,millis()));assert(reboot.model.revision==4&&reboot.model.saved.entries[0].rgb==65280); + fs.failRename=false;request["lifetime"]="temporary";request["composite"]="replace";request["space"]="groups"; + assert(!reboot.apply(request.as(),layout,millis())); + for(auto&p:pixels)p=Rgb(255,255,255); + reboot.model.compose(pixels,layout,mapping,millis()); + assert(pixels[0]==Rgb(255,0,0)&&pixels[18]==Rgb(0,0,0)); + assert(reboot.model.retire()&&!reboot.model.active()&&reboot.model.suppressed); // Direct scene action wins. + request["revision"]=reboot.model.revision;request["lease_id"]=0;request.remove("entries"); + request["frame_rgb"]="00ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff00"; + assert(!reboot.apply(request.as(),layout,millis())); + reboot.model.compose(pixels,layout,mapping,millis());for(auto&p:pixels)assert(p==Rgb(0,255,0)); + request["revision"]=reboot.model.revision;request["lease_id"]=reboot.model.lease;request["frame_rgb"]="ff"; + assert(reboot.apply(request.as(),layout,millis()));assert(reboot.model.frameActive); + request["frame_rgb"]=String(144,'z');assert(reboot.apply(request.as(),layout,millis())); + request.remove("frame_rgb");request["replace"]=false;request["entries"].to(); + const uint32_t frameStarted=reboot.model.temporaryAt; + pakarTestMillis+=10;assert(!reboot.apply(request.as(),layout,millis())); + assert(reboot.model.frameActive&&reboot.model.temporaryAt==frameStarted); + reboot.model.compose(pixels,layout,mapping,millis());for(auto&p:pixels)assert(p==Rgb(0,255,0)); + request["revision"]=reboot.model.revision; + request.remove("frame_rgb");request["action"]="clear";assert(!reboot.apply(request.as(),layout,millis())); + // Every effect is board-clock driven on variable prefix spans, not fixed6. + uint8_t overrides[]={5,7,0,0};assert(layout.configure(overrides,9)); + auto effect=parse(R"({"owner":"test","revision":0,"group_layout_revision":9,"lifetime":"temporary","ttl_ms":10000,"replace":true,"space":"groups","composite":"replace","entries":[{"id":2,"rgb":16711680,"period_ms":1000}]})"); + Store animation;animation.begin(fs,false,5); + for(uint8_t kind=0;kind<=Store::Model::Fade;++kind){ + effect["revision"]=animation.model.revision;effect["lease_id"]=animation.model.lease; + effect["entries"][0]["effect"]=Store::Model::name(Store::Model::Effect(kind)); + assert(!animation.apply(effect.as(),layout,0)); + animation.model.compose(pixels,layout,mapping,250);assert(pixels[4]==Rgb()&&pixels[12]==Rgb()); + if(kind!=Store::Model::Solid)assert(animation.model.animated()); + } + // Fade reaches its exact terminal frame, then ceases perpetual animation. + assert(animation.model.needsFrame(1001,999)); + animation.model.compose(pixels,layout,mapping,1000);assert(pixels[5]==Rgb(255,0,0)); + assert(!animation.model.needsFrame(1001,1000)); + animation.model.temporary.overlay=true; + for(auto&p:pixels)p=Rgb(0,0,255); + animation.model.compose(pixels,layout,mapping,500);assert(pixels[5]==Rgb(127,0,128)); + assert(pixels[4]==Rgb(0,0,255)); + // Immutable original lineage survives cache lookup; later revisions cannot + // turn a retry into confirmation of another editor's current lease. + BoardCore::CommandReceipts receipts;BoardCore::CommandReceipts::Entry *slot=nullptr; + const char *id="0123456789abcdef0123456789abcdef"; + const char *digest="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + assert(receipts.reserve(id,digest,8000,1,slot)==BoardCore::CommandReceipts::Fresh); + slot->accepted=true;slot->observe(10,123); + assert(receipts.reserve(id,digest,8000,2,slot)==BoardCore::CommandReceipts::Cached); + assert(slot->observationMatches(10,123)&&!slot->observationMatches(11,123)&&!slot->observationMatches(10,124)); + // Both full layer descriptions fit the unchanged USB response budget. + HostPresentationStore<80,480> worst;worst.begin(fs,false,0); + worst.model.saved.enabled=worst.model.temporary.enabled=true; + for(auto layer:{&worst.model.saved,&worst.model.temporary})for(auto &s:layer->entries){s.enabled=true;s.rgb=0xffffff;s.periodMs=10000;s.effect=decltype(worst)::Model::Rainbow;s.invert=true;} + assert(worst.json(true).length()<7168); + assert(*fs.files.at("/pixel-corrections.json")==corrections&&*fs.files.at("/scene.bin")==scene); + assert(*fs.files.at("/presentation.json")==appearance); + assert(*fs.files.at("/presentation.pending")==appearancePending); + StateAppearanceSettings paletteReboot;assert(paletteReboot.load(fs)); + assert(paletteReboot.value.states[BoardCore::Protocol::kCellWon].rgb==0x123456); + assert(paletteReboot.value.pulseMs==900); + const auto hostBytes=*fs.files.at("/host-presentation.json"); + paletteReboot.value.pulseMs=950;assert(paletteReboot.save(fs)); + assert(*fs.files.at("/host-presentation.json")==hostBytes); + Store hostReboot;hostReboot.begin(fs,true,300); + assert(hostReboot.model.saved.enabled&&hostReboot.model.saved.entries[0].rgb==65280); + puts("Host presentation: atomic saved/leased layers, wraparound expiry, owner/revision fencing, full-frame bounds, shared effects, variable groups and USB budget passed."); +} diff --git a/tests/key_game_web_test.mjs b/tests/key_game_web_test.mjs index 55020cd5..e14c6da6 100644 --- a/tests/key_game_web_test.mjs +++ b/tests/key_game_web_test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import vm from 'node:vm'; -const source=readFileSync(new URL('../firmware/esp/src/WebUi.h',import.meta.url),'utf8'); +const source=readFileSync(new URL('../web/index.html',import.meta.url),'utf8'); const code=source.split('// BEGIN KEY GAME PRESS CONTROLS')[1].split('// END KEY GAME PRESS CONTROLS')[0]; function fixture(){ const calls=[]; @@ -50,7 +50,7 @@ for(const reject of [false,true]){ const f=fixture();f.arm();f.context.peerHttp.writable=()=>false;f.run('syncKeyGameOwner()'); assert.equal(f.run('keyGameControl.readOnly'),true,'Disconnect disarms'); } -assert(source.includes('indicator.onclick=()=>pressGameKey(key)')); +assert(source.includes('if(!miniGameMomentary())pressGameKey(key)')); assert(!source.includes('data-test-press')&&!source.includes('data-test-key'),'No separate trigger controls'); assert(source.includes('button:disabled{opacity:1;cursor:default}'),'Read-only retains live colors'); console.log('Key game main-box press, read-only, peer and pending guards passed'); diff --git a/tests/key_minigame_integration_test.mjs b/tests/key_minigame_integration_test.mjs new file mode 100644 index 00000000..301ae788 --- /dev/null +++ b/tests/key_minigame_integration_test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync,writeFileSync,mkdirSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import {resolve} from 'node:path'; +const read=path=>readFileSync(new URL('../'+path,import.meta.url),'utf8'); +const app=read('firmware/esp/application.ino'); +const service=read('firmware/esp/src/KeyMiniGameService.h'); +test('actual AVR key callback compiles for both roles and preserves their input owners',()=>{ + const avr=read('firmware/avr/avr.ino'); + const start=avr.indexOf('void boardKeyGesture('),end=avr.indexOf('void serviceInputs(',start); + assert(start>=0&&end>start,'Extract the actual shared callback, not a copied implementation'); + const fixture=`#include +#include +#define _BV(bit) (1U << (bit)) +uint32_t micros(){return 0;} +uint8_t stableInputs=0;bool stableInput9=false;unsigned dirty=0,actions=0; +void markStateDirty(){++dirty;} +void onInputChanged(uint8_t,bool pressed){assert(pressed);++actions;} +struct Controller { + unsigned keys=0,edges=0;bool menu=false,espMenu=false; + void key(uint8_t,KeyEvent,uint8_t,uint32_t,uint32_t){++keys;} + bool menuActive()const{return menu;}bool espMenuActive()const{return espMenu;} +#if PAKAR_FIRST_WINS_ENABLED + void gameInput(uint8_t,KeyEvent){++edges;} +#endif +}pcController; +struct Panel {bool held=false;bool captured()const{return held;}}frontPanel; +#if PAKAR_FIRST_WINS_ENABLED +bool espGameOwned=false; +#endif +${avr.slice(start,end)} +int main(){ + boardKeyGesture(0,KeyEvent::Down,nullptr);assert(stableInputs==1&&dirty==1&&pcController.keys==1&&actions==1); + boardKeyGesture(0,KeyEvent::Up,nullptr);assert(stableInputs==0&&dirty==2&&pcController.keys==2&&actions==1); + boardKeyGesture(8,KeyEvent::Down,nullptr);assert(stableInput9&&actions==2); + boardKeyGesture(8,KeyEvent::Up,nullptr);assert(!stableInput9&&actions==2); + pcController.menu=true;boardKeyGesture(1,KeyEvent::Down,nullptr);assert(actions==2); + pcController.menu=false;pcController.espMenu=true;boardKeyGesture(1,KeyEvent::Down,nullptr);assert(actions==2); + pcController.espMenu=false;frontPanel.held=true;boardKeyGesture(1,KeyEvent::Down,nullptr);assert(actions==2); + frontPanel.held=false;boardKeyGesture(1,KeyEvent::Click,nullptr);assert(actions==2); +#if PAKAR_FIRST_WINS_ENABLED + const auto priorKeys=pcController.keys;espGameOwned=true; + boardKeyGesture(2,KeyEvent::Down,nullptr);assert((stableInputs&4)&&pcController.edges==1); + boardKeyGesture(2,KeyEvent::Up,nullptr);assert(!(stableInputs&4)&&pcController.edges==2); + boardKeyGesture(2,KeyEvent::Click,nullptr);assert(pcController.edges==2&&pcController.keys==priorKeys&&actions==2); +#else + assert(pcController.edges==0&&pcController.keys==8); +#endif +} +`; + const directory=resolve('build/native');mkdirSync(directory,{recursive:true}); + const cpp=resolve(directory,'avr_key_gesture_fixture.cpp'),exe=resolve(directory,'avr_key_gesture_test.exe'); + let previous;try{previous=readFileSync(cpp,'utf8')}catch(error){if(error.code!=='ENOENT')throw error;} + if(previous!==fixture)writeFileSync(cpp,fixture); + for(const enabled of [0,1]){ + const result=spawnSync(process.env.CXX||'g++',['-std=c++17','-O2','-g0','-Wall','-Wextra','-Werror', + '-DPAKAR_FIRST_WINS_ENABLED='+enabled,'-I',resolve('tests/access_stubs'),'-I',resolve('libraries/BoardCore/src'),cpp,'-o',exe],{encoding:'utf8',timeout:60000}); + assert.equal(result.status,0,result.error?.message||result.stderr); + const run=spawnSync(exe,[],{encoding:'utf8',timeout:10000}); + assert.equal(run.status,0,run.error?.message||run.stderr); + } +}); +test('every minigame command is admitted and handled by the existing correlated command path',()=>{ + const admission=app.slice(app.indexOf('const bool supported=command=='),app.indexOf('const bool supported=command==')+1600); + for(const name of ['minigame-configure','minigame-control','minigame-input']){ + assert(admission.includes(`command=="${name}"`),name+' missing from admission'); + assert(app.includes(`command=="${name}"`)); + assert(service.includes(`command=="${name}"`)); + } + assert.match(app,/commandReceipts\.reserve/); + assert.match(app,/else status=buildStatusJson\(\)/); +}); +test('AVR ownership is explicit and raw edges use the established event queue',()=>{ + const avr=read('firmware/avr/avr.ino'),runtime=read('firmware/avr/PcControllerRuntime.cpp'); + assert.match(avr,/requestedOwner=frame\.payloadLength==7&&frame\.payload\[4\]/); + assert.match(avr,/if\(espGameOwned\)\{\s*if\(event==KeyEvent::Down\|\|event==KeyEvent::Up\)pcController\.gameInput/); + assert.match(runtime,/if\(gameOwned_&&eventCount_\)\{drainOne\(\);return;\}/); + assert.match(app,/frame\.payloadLength==8&&frame\.payload\[0\]==ControllerProtocol::Event/); + assert.match(app,/frame\.payload\[4\]==0&&\(frame\.payload\[6\]\|uint16_t\(frame\.payload\[7\]\)<<8\)==miniEpoch/); + assert.match(service,/if\(\+\+miniEpoch==0\)\+\+miniEpoch/); + assert.match(service,/avrMiniEpoch==miniEpoch/); +}); +test('remote holds expire and game settings never save from the frame loop',()=>{ + assert.match(service,/uint32_t\(now-miniRemoteAt\[k-1\]\)>=5000/); + const loop=service.slice(service.indexOf('void serviceMiniGame'),service.indexOf('bool miniPanel')); + assert.doesNotMatch(loop,/\.save\(|LittleFS|delay\(/); + assert.match(service,/if\(!next\.save\(LittleFS\)\)/); + const configure=service.slice(service.indexOf('if(command=="minigame-configure")'),service.indexOf('}else if(command=="minigame-control")')); + assert.match(configure,/#if defined\(ARDUINO_ARCH_ESP32\)[\s\S]*ScopedLedFlashPause pause;\s+if\(!pause\)\{result=F\("led_flash_guard_busy"\);return false;\}\s+#endif\s+if\(!next\.save\(LittleFS\)\)/, + 'Persistent configuration shares the LED flash fence; refusal cannot mutate the game'); + assert(configure.indexOf('if(!pause)'){ + const panel=read('firmware/esp/src/SemanticPanelService.h'),led=read('firmware/esp/src/LedGrid.cpp'); + assert.match(panel,/!updating&&miniReady\(\)&&miniGame\.view\(\)\.timed&&miniGame\.running\(\)/); + assert.match(panel,/semanticPanel\.persistent\(updating\|\|gameCountdown,text\)/); + assert.match(led,/Presentation::composeLayer\(\*gameLayer_/); + assert.match(led,/millis\(\)-gamePresentationAt_/); + assert.match(service,/grid\.setGamePresentationEpoch\(miniGame\.presentationAt\(\)\)/); + assert.match(service,/mode!=G::PressToWin&&\(v\.phase==G::Ready\|\|v\.phase==G::Stopped\)/); + assert.match(service,/resultCue=kCueIncorrect/); + assert.match(service,/resultCue=keySettings\.winnerCue/); +}); diff --git a/tests/key_minigame_test.cpp b/tests/key_minigame_test.cpp new file mode 100644 index 00000000..afb3b340 --- /dev/null +++ b/tests/key_minigame_test.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +using Game=BoardCore::KeyMiniGame; +using Config=Game::Config; +static void tap(Game &g,uint8_t key,uint32_t now){assert(g.input(key,true,now));assert(g.input(key,false,now+1));} +static Config config(Game::Mode mode,uint16_t mask=15){auto c=Config::defaults(mode);c.enabledMask=mask;c.introEnabled=false;return c;} +static void configure(Game &g,Config c,uint32_t now=0){assert(g.configure(c,now));assert(g.phase()==Game::Ready);} +static void basics(){ + const auto simon=Config::defaults(Game::Simon);assert(simon.rounds==1&&simon.sequenceLength==8&&simon.valid()); + Game g;auto c=config(Game::PressToWin);configure(g,c); + assert(c.limitMs==0);g.tick(600000);assert(g.phase()==Game::Ready); + tap(g,2,600000);assert(g.view().winner==2&&g.phase()==Game::Success); + assert(!g.input(1,true,600010));g.reset(600020);assert(g.view().winner==0); + assert(!g.input(9,true,600030)); + c.outputs[0]={Game::KeyWinner,2};c.outputs[1]={Game::KeyLost,1};c.outputs[2]={Game::RoundSuccess,0}; + configure(g,c);tap(g,2,10);assert(g.outputMask()==7);g.reset(20);assert(g.outputMask()==0); + tap(g,2,21);assert(g.outputMask()==7);g.stop(23);assert(g.outputMask()==0&&g.view().winner==0); + c.outputs[0]={Game::KeyHeld,0};assert(!g.configure(c,30));assert(g.config().outputs[0].key==2); + assert(!Game::available(Game::Mode(255)));Game::Mode mode;assert(!Game::parse("made-up",mode)); +} +static void timers(){ + Game g;auto c=config(Game::PressToWin);c.limitMs=1000;c.warningMs=700;c.urgentMs=200; + configure(g,c,0xfffffff0);assert(g.start(0xfffffff0,7)); + g.tick(0x180);assert(g.view().elapsedMs==400&&g.view().warning==1); + assert(g.pause(0x180));g.tick(100000);assert(g.view().elapsedMs==400); + assert(g.resume(100000));g.tick(100400);assert(g.view().warning==2); + g.tick(100600);assert(g.phase()==Game::Timeout);assert(!g.input(1,true,100601)); + g.reset(0);c.urgentMs=800;c.warningMs=700;assert(!g.configure(c,0)); +} +static void reaction(){ + Game g;auto c=config(Game::Illuminated);c.rounds=2;c.fixedTarget=2; + configure(g,c);assert(g.start(100,1));assert(g.view().target==2); + tap(g,2,190);assert(g.phase()==Game::Feedback&&g.view().lastReactionMs==90); + assert(!g.input(3,true,200));g.tick(430);assert(g.phase()==Game::Input&&g.view().round==2); + tap(g,2,500);g.tick(740);assert(g.phase()==Game::Success&&g.view().bestReactionMs==70); + assert(c.windowMs==10000);configure(g,c);g.start(100,1);g.tick(10099);assert(g.phase()==Game::Input); + g.tick(10100);assert(g.phase()==Game::Timeout); + c.windowMs=20000;configure(g,c);g.start(0,1);g.tick(19999);assert(g.phase()==Game::Input);g.tick(20000);assert(g.phase()==Game::Timeout); + c=config(Game::ReactionChase);configure(g,c);g.start(0,1);const auto first=g.view().target; + g.tick(c.windowMs);assert(g.phase()==Game::Input&&g.view().target!=first); +} +static void colorMatch(){ + const uint16_t masks[]={3,15,63,511,0x125}; + for(uint16_t mask:masks)for(uint8_t seed=1;seed<=32;++seed){ + Game trial;auto rules=config(Game::ColorMatch,mask);configure(trial,rules);trial.start(0,seed); + uint32_t at=1;Game::Style seen[18]; + for(uint8_t key=1;key<=9;++key)if(mask&Game::keyBit(key))for(uint8_t tries=0;tries unsigned panelNotifications=0;uint8_t panelKind=0;uint16_t panelValue=0; void panelNotice(uint8_t kind,uint16_t value,uint8_t){++panelNotifications;panelKind=kind;panelValue=value;} +void hostPresentationComposition(){ + using namespace BoardCore::Protocol; + const auto savedNow=pixelTestNow;LedGrid grid;LedGrid::Presentation host;grid.begin();grid.setPresentation(&host); + const auto rgb=[&](){const auto f=grid.pixelFrameJson();return f.substr(f.find("\"rgb\":\"")+7,LedGrid::kPixels*6);}; + const auto base=rgb();host.temporary.enabled=true;host.temporary.overlay=true;host.temporary.physical=true; + host.temporary.entries[0].enabled=true;host.temporary.entries[0].rgb=0x00ff00;host.revision=1; + grid.presentationChanged();grid.service(++pixelTestNow); + assert(rgb().substr(0,36)==String("00ff0000ff0000ff0000ff0000ff0000ff00")); + assert(rgb().substr(36)==base.substr(36));for(uint8_t id=1;id<=80;++id)assert(grid.state(id)==kCellWhite); + host.temporary.entries[0].effect=LedGrid::Presentation::Ring;grid.presentationChanged(); + pixelTestNow+=50;assert(grid.needsRender(pixelTestNow));grid.service(pixelTestNow); + const auto first=rgb();pixelTestNow+=50;grid.service(pixelTestNow);assert(rgb()!=first); // Even GPIO-static shares explicit host effects. + assert(grid.resetWhite());assert(!host.active()&&host.suppressed);grid.service(++pixelTestNow);assert(rgb()==base); + assert(grid.pixelFrameJson().find("\"presentation_revision\":2")!=String::npos); + pixelTestNow=savedNow; +} String withoutPending(const String& frame){ String value=frame;const auto at=value.find(",\"pending\":");assert(at!=String::npos); const auto end=value.find(',',at+1);assert(end!=String::npos);value.erase(at,end-at);return value; @@ -48,6 +64,40 @@ void rainbowClockCadence(){ assert(stationary.pattern()==LedGrid::kPatternNone && rgb(stationary)==still); pixelTestNow=savedNow; } +void matrixFireRendering(){ + const auto savedNow=pixelTestNow; + const auto rgb=[](const LedGrid &g){const auto f=g.pixelFrameJson();return f.substr(f.find("\"rgb\":\"")+7,LedGrid::kPixels*6);}; + const String black(LedGrid::kPixels*6,'0'); + uint8_t mapping[80];for(uint8_t i=0;i<80;++i)mapping[i]=BoardCore::TableCellMapping::physicalForLogical(i+1); + for(bool grouped:{true,false})for(uint16_t rotation:{0,90,180,270}){ + LedGrid grid;grid.begin();grid.setPhysicalMapping(mapping,80);grid.setStaticMode(grouped); + BoardCore::StateAppearance appearance;appearance.fireRotation=rotation; + assert(grid.configureAppearance(appearance));assert(grid.setPattern(LedGrid::kPatternFire)); + pixelTestNow+=1000;grid.service(pixelTestNow);assert(rgb(grid)==black); + const uint32_t start=pixelTestNow;BoardCore::MatrixFire<20,8> expected;expected.restart(rotation);expected.advance(start); + for(unsigned tick=1;tick<=32;++tick){ + pixelTestNow=start+tick*16;assert(grid.needsRender(pixelTestNow));grid.service(pixelTestNow);expected.advance(pixelTestNow); + const auto frame=rgb(grid); + for(uint8_t cell=0;cell<80;++cell){ + const uint8_t heat=expected.sample(2*(cell%10)+(cell/10)%2,cell/10); + const uint16_t physical=mapping[cell]-1; + const char hex[]="0123456789abcdef"; + String color;color+=hex[heat>>4];color+=hex[heat&15];color+="0000"; // Host HeatColor double. + for(unsigned pixel=0;pixel<6;++pixel)assert(frame.substr((physical*6+pixel)*6,6)==color); + assert(grid.state(cell+1)==BoardCore::Protocol::kCellWhite); + } + } + assert(rgb(grid)!=black); + const auto frame=rgb(grid);grid.service(pixelTestNow);assert(rgb(grid)==frame); // No synthetic step per render. + assert(grid.setPattern(LedGrid::kPatternFire));grid.service(pixelTestNow);assert(rgb(grid)==black); // Same-effect replay. + grid.setPattern(LedGrid::kPatternNone);grid.setPattern(LedGrid::kPatternFire);grid.service(pixelTestNow);assert(rgb(grid)==black); + } + LedGrid grid;grid.begin();auto layout=grid.groupLayout();uint8_t counts[80]{};counts[0]=3;counts[1]=9; + assert(layout.configure(counts,9));assert(grid.configureGroups(layout)); + grid.setPattern(LedGrid::kPatternFire);grid.service(++pixelTestNow);pixelTestNow+=16;grid.service(pixelTestNow); + assert(grid.groupLayout().count(1)==3&&grid.groupLayout().count(2)==9); // Never normalize user groups. + pixelTestNow=savedNow; +} void cloudAnimationProgress(){ const auto savedNow=pixelTestNow; LedGrid grid;pixelTestNow=10;grid.begin();grid.setPattern(LedGrid::kPatternRainbow); @@ -154,6 +204,8 @@ void repeatedFadeKeepsDeadline(){ pixelTestNow=savedNow; } int main(){ + matrixFireRendering(); + hostPresentationComposition(); repeatedFadeKeepsDeadline(); semanticAppearance(); sharedStateMotion(); @@ -182,8 +234,8 @@ int main(){ const auto fireFrame=grid.pixelFrameJson();const auto fireAt=fireFrame.find("\"rgb\":\"")+7; pixelTestNow+=1000;assert(grid.needsRender(pixelTestNow));grid.service(pixelTestNow); frame=grid.pixelFrameJson(); - assert(frame.substr(frame.find("\"rgb\":\"")+7,LedGrid::kPixels*6)==fireFrame.substr(fireAt,LedGrid::kPixels*6)); - assert(grid.pattern()==LedGrid::kPatternFire); // Maintenance is not an animation tick. + assert(frame.substr(frame.find("\"rgb\":\"")+7,LedGrid::kPixels*6)!=fireFrame.substr(fireAt,LedGrid::kPixels*6)); + assert(grid.pattern()==LedGrid::kPatternFire); // Explicit Fire animates in static grouped mode too. grid.setPattern(LedGrid::kPatternRainbow);grid.service(++pixelTestNow); frame=grid.pixelFrameJson();staticAt=frame.find("\"rgb\":\"")+7; for(unsigned i=1;i<6;++i)assert(frame.substr(staticAt,6)==frame.substr(staticAt+i*6,6)); diff --git a/tests/led_worker_test.cpp b/tests/led_worker_test.cpp index 171cfa69..c22ab43a 100644 --- a/tests/led_worker_test.cpp +++ b/tests/led_worker_test.cpp @@ -127,7 +127,7 @@ int esp_pm_lock_create(int,int,const char*,void** handle){assert(!Fixture::pmExi int esp_pm_lock_acquire(void*){assert(Fixture::pmExists&&!Fixture::pmAcquired);Fixture::pmAcquired=true;return ESP_OK;} int esp_pm_lock_release(void*){assert(!Fixture::isLive()&&Fixture::pmAcquired);Fixture::pmAcquired=false;return ESP_OK;} int esp_pm_lock_delete(void*){assert(Fixture::pmExists&&!Fixture::pmAcquired);Fixture::pmExists=false;++Fixture::pmDeletes;return ESP_OK;} -int rmt_new_tx_channel(const rmt_tx_channel_config_t* config,rmt_channel_handle_t* channel){assert(Fixture::pinOutput&&!Fixture::channelExists);++Fixture::channelCreates;assert(config->flags.with_dma&&config->mem_block_symbols==2046&&config->intr_priority==3&&config->resolution_hz==40000000&&config->trans_queue_depth==1&&config->gpio_num==4);if(Fixture::channelCreateError)return Fixture::channelCreateError;Fixture::channelExists=true;*channel=reinterpret_cast(1);return ESP_OK;} +int rmt_new_tx_channel(const rmt_tx_channel_config_t* config,rmt_channel_handle_t* channel){assert(Fixture::pinOutput&&!Fixture::channelExists);++Fixture::channelCreates;assert(config->flags.with_dma&&config->mem_block_symbols==2046&&config->intr_priority==0&&config->resolution_hz==40000000&&config->trans_queue_depth==1&&config->gpio_num==4);if(Fixture::channelCreateError)return Fixture::channelCreateError;Fixture::channelExists=true;*channel=reinterpret_cast(1);return ESP_OK;} namespace PakarS3 { int newCacheSafeLedChannel(const rmt_tx_channel_config_t *config,rmt_channel_handle_t *channel){return rmt_new_tx_channel(config,channel);} LedDmaIrqReceipt ledDmaIrqReceipt(){return {PAKAR_S3_LED_RMT_ENABLED!=0,42,1,23,3};} // Actual allocator is tested separately. diff --git a/tests/matrix_fire_test.cpp b/tests/matrix_fire_test.cpp new file mode 100644 index 00000000..07230e77 --- /dev/null +++ b/tests/matrix_fire_test.cpp @@ -0,0 +1,49 @@ +#include +#include "StateAppearanceSettings.h" +#include +#include + +int main(){ + using Fire=BoardCore::MatrixFire<20,8>; + Fire fast,slow,reverse,right,left; + reverse.restart(180);right.restart(90);left.restart(270); + for(auto*fire:{&fast,&slow,&reverse,&right,&left})fire->advance(UINT32_MAX-63U); + for(unsigned tick=1;tick<=8;++tick){ + const auto now=uint32_t(UINT32_MAX-63U+tick*16U); + fast.advance(now);reverse.advance(now);right.advance(now);left.advance(now); + } + slow.advance(64); // Eight due steps, including millis rollover. + unsigned lit=0; + for(unsigned y=0;y<8;++y)for(unsigned x=0;x<20;++x){ + assert(fast.sample(x,y)==slow.sample(x,y)); + assert(fast.sample(x,y)==reverse.sample(19-x,7-y)); + assert(right.sample(x,y)==left.sample(19-x,7-y)); + lit+=fast.sample(x,y)>0; + } + assert(lit>80);assert(fast.sample(20,0)==0&&fast.sample(0,8)==0); + fast.restart();fast.advance(90000);for(unsigned y=0;y<8;++y)for(unsigned x=0;x<20;++x)assert(!fast.sample(x,y)); + fast.advance(90016);unsigned base=0;for(unsigned x=0;x<20;++x)base+=fast.sample(x,7)>0; + assert(base>10); // Ignition spans the width, not one strip-end spark. + fast.advance(UINT32_MAX); // Bounded stall recovery, no unbounded catch-up. + fs::FS files;StateAppearanceSettings settings;JsonDocument patch; + const String scene="untouched scene bytes",corrections="untouched corrections bytes"; + files.files["/scene.bin"]=std::make_shared(scene); + files.files["/pixel-corrections.json"]=std::make_shared(corrections); + assert(!deserializeJson(patch,R"({"enabled":true,"fire_rotation":180})")); + assert(settings.patch(patch.as())&&settings.value.fireRotation==180); + assert(settings.save(files));const auto writes=files.writes;assert(settings.save(files)&&files.writes==writes); + StateAppearanceSettings loaded;assert(loaded.load(files)&&loaded.value.fireRotation==180); + for(const char*bad:{"-90","45","360","90.5","true","\"90\""}){ + const String json=String("{\"enabled\":false,\"fire_rotation\":")+bad+"}"; + assert(!deserializeJson(patch,json));assert(!settings.patch(patch.as())); + assert(settings.value.enabled&&settings.value.fireRotation==180); // All-or-nothing validation. + } + // Existing palettes without the additive preference retain zero rotation. + files.files["/presentation.json"]=std::make_shared("{\"enabled\":true,\"fade_ms\":500}"); + StateAppearanceSettings legacy;assert(legacy.load(files)&&legacy.value.fireRotation==0&&legacy.value.fadeMs==500); + assert(*files.files["/scene.bin"]==scene&&*files.files["/pixel-corrections.json"]==corrections); + files.failRename=true;patch.clear();patch["fire_rotation"]=90; + assert(settings.patch(patch.as())&&!settings.save(files)); + StateAppearanceSettings retained;assert(retained.load(files)&&retained.value.fireRotation==0); + puts("Fire cold replay, four directions, full-width ignition, rollover/cadence, bounded catch-up and isolated durable rotation passed"); +} diff --git a/tests/network_observation_test.cpp b/tests/network_observation_test.cpp new file mode 100644 index 00000000..edb47077 --- /dev/null +++ b/tests/network_observation_test.cpp @@ -0,0 +1,20 @@ +#include "../firmware/esp/src/NetworkObservationPolicy.h" +#include +#include +int main(){ + using namespace PakarNetworkObservation; + uint32_t epoch=0; + assert(httpDate("Wed, 28 Oct 2026 12:34:56 GMT",epoch)&&epoch==1793190896UL); + assert(httpDate("Thu, 29 Feb 2024 00:00:00 GMT",epoch)); + for(const char *bad:{"Wed, 29 Feb 2023 00:00:00 GMT","Wed, 28 Oct 2026 24:34:56 GMT", + "Wed, 28 Oct 2026 12:34:60 GMT","Tue, 28 Oct 2026 12:34:56 GMT", + "Wed, 28 Oct 2026 12:34:56 UTC","Wed, 28 Oct 2026 12:34:56 GMTX", + "Wed, 28 Oct 2026 12:34:56 GMT\r\n","Wed, 00 Oct 2026 12:34:56 GMT"})assert(!httpDate(bad,epoch)); + for(const char *good:{"1.1.1.1","8.8.8.8","93.184.215.14"})assert(publicIpv4(good)); + for(const char *bad:{"","1.1.1.1x","01.1.1.1","1.1.1.256","127.0.0.1","10.0.0.1", + "192.168.1.1","172.16.0.1","169.254.0.1","100.64.0.1","224.1.1.1","255.255.255.255", + "192.0.2.1","198.51.100.5","203.0.113.1","0.0.0.0"})assert(!publicIpv4(bad)); + assert(hostname("pool.ntp.org")&&hostname("ledcontroller.local")); + for(const char *bad:{"","-host","host-","host..local","host/evil","user@host","host:80","host\r\n"})assert(!hostname(bad)); + std::cout<<"Network observation: strict UTC dates, weekday, public IPv4 and host bounds passed\n"; +} diff --git a/tests/network_tools_settings_test.cpp b/tests/network_tools_settings_test.cpp new file mode 100644 index 00000000..7f061c8e --- /dev/null +++ b/tests/network_tools_settings_test.cpp @@ -0,0 +1,29 @@ +#include "../firmware/esp/src/NetworkToolsSettings.h" +#include +#include +bool patch(NetworkToolsSettings &s,const char *raw){JsonDocument d;assert(!deserializeJson(d,raw));String error;return s.patch(d.as(),error);} +int main(){ + fs::FS fs;NetworkToolsSettings s;s.begin(fs,true);String error; + assert(s.ntpEnabled&&s.ntpDhcp&&s.ntpServer=="pool.ntp.org"&&!s.dateAutomatic&&!s.publicIpAutomatic&&fs.writes==0); + assert(patch(s,R"({"ntp_server":"time.example.org","ntp_dhcp":false,"refresh_seconds":120})")); + assert(s.save(error)&&fs.writes==1); + assert(s.save(error)&&fs.writes==1); // No redundant flash writes. + NetworkToolsSettings restored;restored.begin(fs,true); + assert(restored.ntpServer==s.ntpServer&&!restored.ntpDhcp&&restored.refreshSeconds==120); + for(const char *bad:{R"({"ntp_enabled":"true"})",R"({"ntp_server":"user@host"})", + R"({"http_date_url":"http://example.com/"})",R"({"public_ip_url":"https://user@host/"})", + R"({"public_ip_url":"https://example.com/\u0000evil"})",R"({"public_ip_url":"https://example.com/#fragment"})", + R"({"refresh_seconds":59})",R"({"refresh_seconds":86401})",R"({"refresh_seconds":60.5})", + R"({"epoch":1793190896})",R"({"public_ip":"8.8.8.8"})",R"({})"}){ + NetworkToolsSettings candidate=restored;assert(!patch(candidate,bad)); + } + NetworkToolsSettings candidate=restored;assert(patch(candidate,R"({"ntp_enabled":false})")); + fs.failWrite=true;assert(!candidate.save(error));fs.failWrite=false; + NetworkToolsSettings afterWrite;afterWrite.begin(fs,true);assert(afterWrite.ntpEnabled); + fs.failRename=true;assert(!candidate.save(error));fs.failRename=false; + NetworkToolsSettings afterRename;afterRename.begin(fs,true);assert(afterRename.ntpEnabled); + NetworkToolsSettings unavailable;unavailable.begin(fs,false);assert(!unavailable.save(error)); + assert(NetworkToolsSettings::url("https://example.com/date?source=clock")); + assert(!NetworkToolsSettings::url("https://example.com:443/")); + std::cout<<"Actual network-tools settings: typed patches, protected observations, staged persistence and write/rename failure passed\n"; +} diff --git a/tests/network_tools_wiring_test.mjs b/tests/network_tools_wiring_test.mjs new file mode 100644 index 00000000..e1874be9 --- /dev/null +++ b/tests/network_tools_wiring_test.mjs @@ -0,0 +1,35 @@ +// Integration/source invariants, not device or Internet acceptance. The date +// policy/settings fixtures execute actual C++; the SDK build checks these APIs. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const read = p => readFileSync(new URL('../' + p, import.meta.url), 'utf8'); +const app = read('firmware/esp/application.ino'); +const binding = read('firmware/esp/src/NetworkToolsBinding.h'); +const worker = read('firmware/esp/src/NetworkTools.h'); +const usb = read('firmware/esp/src/UsbApiService.h'); +const ntp = app.slice(app.indexOf('void configureNtp() {'), app.indexOf('bool applyStreamPacket(')); +assert(ntp.indexOf('esp_netif_init()') < ntp.indexOf('esp_sntp_stop()')); +assert(ntp.includes('configTime(0, 0, ntpServerName)')); +assert(app.includes('char ntpServerName[254]')); +const setup = app.slice(app.indexOf('void setup() {')); +assert(setup.indexOf('networkToolsSettings.begin(') < setup.indexOf('configureNtp();')); +assert(setup.indexOf('configureNtp();') < setup.indexOf('networkConfig.configure(')); +assert(!app.includes('WiFi.scanNetworks(false')); +assert(worker.includes('startPending()') && worker.includes('HTTPC_DISABLE_FOLLOW_REDIRECTS')); +assert(worker.includes('useBuiltinCACertBundle()') && !worker.includes('setInsecure')); +assert(worker.includes('NetworkClientSecure::connect(ip_,port,host,')); +assert(worker.includes('deadline_(millis()+9000U)')); +assert(worker.includes('length>64') && worker.includes('memchr(bytes,0,length)')); +for (const name of ['network-tools', 'configure-network-tools', 'network-probe', 'set-clock', 'wifi-scan-results']) { + assert(app.includes('command=="' + name + '"'), name); +} +for (const route of ['/api/time', '/api/network/tools', '/api/network/probe', '/api/wifi/scan', '/api/network']) { + assert(usb.includes('path=="' + route + '"'), route); +} +assert(binding.includes('publishPcEvent(body)') && binding.includes('cloudServices.requestTelemetry()')); +assert(read('firmware/esp/src/MaintenanceRoutes.h').includes('add("network_tools",networkToolsJson())')); +assert(read('mobile/lib/peer_context.dart').includes("'network-tools'")); +assert(read('mobile/lib/main.dart').includes("'Advanced…'")); +assert(!read('mobile/lib/main.dart').includes('Advanced MQTT / bridge')); +assert(read('web/index.html').includes('/api/wifi/scan?results=true')); +console.log('Network tools wiring: first-boot TCP/IP/SNTP order, stable names, async scans, verified bounded TLS, shared USB/cloud routes and Advanced capability gates passed; not live network proof.'); diff --git a/tests/pc_contract_test.cpp b/tests/pc_contract_test.cpp index 65ae5c73..373188d9 100644 --- a/tests/pc_contract_test.cpp +++ b/tests/pc_contract_test.cpp @@ -25,7 +25,10 @@ struct Hardware final : PcControllerHardware { ++commands; return P::kNoError; } void applySettings(const BoardSettings &s) override { live.brightness = s.displayBrightness; live.muted = s.flags & 1; } - P::Error menuAction(uint8_t, uint8_t page) override { return page == 2 ? P::kUnsafe : P::kNoError; } + P::Error menuAction(uint8_t action, uint8_t page) override { + if (page == 1) live.outputs += action == 2 ? -1 : 1; + return page == 2 ? P::kUnsafe : P::kNoError; + } void showMenu(uint8_t,bool,bool) override {} void safeStop() override { ++stops; live.outputs = 0; } bool ready() const override { return canReceive; } @@ -345,7 +348,78 @@ void companionMirroring() { h.frames.clear();r.companionPoll(3022,3022000);assert(h.frames.empty()); assert(h.commands==0 && h.stops==0 && !r.menuActive() && !r.running()); } +void gameInputOwnership(){ + EEPROM.reset();Hardware h;PcControllerRuntime r(h);r.begin(0); + r.companionHeartbeat(10);r.gameOwner(true);h.live.outputs=3; + for(uint8_t key=0;key<9;++key)r.gameInput(key,KeyEvent::Down); + for(uint8_t i=0;i<9;++i)r.service(20+i,(20+i)*1000); + unsigned keys=0;for(const auto &f:h.frames)if(f.p.size()==8&&f.p[0]==PC::Event&&f.p[1]==1){ + assert(f.p[4]==0&&f.p[6]==1&&f.p[7]==0);++keys; + } + assert(keys==9&&r.lostEvents()==0); + h.frames.clear();r.gameInput(0,KeyEvent::Down);r.gameOwner(true,0x1234); + assert(h.live.outputs==0&&h.stops==1); + h.live.outputs=7;r.gameOwner(true,0x1234); + assert(h.live.outputs==7&&h.stops==1); // Same-epoch retry is not a reset. + r.service(40,40000); // Old queued Down was retired by the new round. + for(const auto &f:h.frames)assert(f.p.empty()||f.p[0]!=PC::Event); + h.frames.clear();r.gameInput(1,KeyEvent::Down);r.service(41,41000); + assert(h.frames.size()==1&&h.frames[0].p.size()==8&&h.frames[0].p[6]==0x34&&h.frames[0].p[7]==0x12); + r.service(3010,3010000);assert(!r.gameOwnerActive()&&h.live.outputs==0&&h.stops==2); + r.companionHeartbeat(3011);r.service(3011,3011000);assert(!r.gameOwnerActive()); +} +void gameOutputOwnership(){ + EEPROM.reset();Hardware h;PcControllerRuntime r(h);r.begin(0); + // Handoff cancels a scheduled macro, irrespective of keep-output options. + send(r,h,PC::MacroStart,{1,ControllerCore::MacroRing::KeepOutputsOnCancel,1,0}); + send(r,h,PC::MacroStep,{0,0,0,1,0,100,0,0,0,PC::Buzzer,4,0xE8,3,20,0}); + send(r,h,PC::MacroStep,{1});h.live.outputs=7; + r.companionHeartbeat(100);r.gameOwner(true); + assert(h.stops==1&&h.live.outputs==0); + h.live.outputs=0x5a;r.gameOwner(true);r.service(101,101000); + assert(h.commands==0&&h.live.outputs==0x5a&&h.stops==1); + send(r,h,PC::MacroStep,{2}); + assert(h.frames[0].p[2]==ControllerCore::MacroRing::Cancelled); + send(r,h,PC::MacroStart,{2,0,0,0});error(h,PC::MacroStart,P::kBusy); + send(r,h,PC::MacroCancel,{0});error(h,PC::MacroCancel,P::kBusy); + for(uint8_t action:{0,1,3,4,6,7}){ + send(r,h,PC::MacroStep,{action});error(h,PC::MacroStep,P::kBusy); + } + send(r,h,PC::MenuSetPage,{1}); + send(r,h,PC::MenuAction,{3});error(h,PC::MenuAction,P::kBusy); + send(r,h,PC::RemoteKeyGesture,{2,static_cast(KeyEvent::Down)}); + error(h,PC::RemoteKeyGesture,P::kBusy); + assert(h.live.outputs==0x5a); + // Stored host-loss SafeStop and a host running flag do not own game outputs. + AutomationRecord rule{0xFF,1,static_cast(AutomationEventKind::Host),0,0xFF, + static_cast(AutomationActionKind::SafeStop),0,0,0,0}; + assert(boardAutomations.put(rule)); + send(r,h,PC::ProgramState,{1},200); + r.companionHeartbeat(5200);r.service(5200,5200000); + assert(h.live.outputs==0x5a&&h.stops==1&&!r.running()); + // Explicit reset and companion expiry retain their safety authority. + send(r,h,PC::Reset,{0},5201);assert(h.live.outputs==0&&h.stops==2); + h.live.outputs=7;r.service(8200,8200000); + assert(h.live.outputs==0&&h.stops==3&&!r.gameOwnerActive()); + + // Recorded relay replay is retired too; releasing ownership never resumes it. + EEPROM.reset();Hardware recorded;PcControllerRuntime playback(recorded);playback.begin(0); + send(playback,recorded,PC::MacroStep,{3,1}); + recorded.live.outputs=3;playback.service(110,110000); + send(playback,recorded,PC::MacroStep,{4}); + send(playback,recorded,PC::MacroStep,{6}); + playback.companionHeartbeat(120);playback.gameOwner(true); + recorded.live.outputs=0x55;playback.service(121,130000); + assert(recorded.live.outputs==0x55&&recorded.commands==0); + send(playback,recorded,PC::MacroStep,{5,0,0}); + assert(recorded.frames[0].p[2]==0x80); // Saved recording remains readable. + send(playback,recorded,PC::MacroStep,{6});error(recorded,PC::MacroStep,P::kBusy); + playback.gameOwner(false);playback.service(122,140000); + assert(recorded.live.outputs==0x55&&recorded.commands==0); +} int main() { + gameInputOwnership(); + gameOutputOwnership(); settingsTransactions(); dispatcher(); macroAndAutomations(); transport(); keysAndDisplay(); stripAndChunks(); polling(); savedMenuPage(); companionMirroring(); puts("PCController profile: dispatch, safety, settings/automation torn writes, macros, transport, keys, TM1637 passed."); } diff --git a/tests/peer_fence_test.mjs b/tests/peer_fence_test.mjs index 111778c5..b4f5e303 100644 --- a/tests/peer_fence_test.mjs +++ b/tests/peer_fence_test.mjs @@ -14,7 +14,7 @@ assert.match(firmware,/webAccess.configurePeerFence\(pcBoardId,\s*pcBootId,\s*ho assert.doesNotMatch(firmware,/webAccess.configurePeerFence\(hostname,/, 'A configurable hostname must not replace the stable board identity'); assert.match(firmware,/http_peer_fencing/); -const dispatchAuth = firmware.slice(firmware.indexOf('bool authenticate()'),firmware.indexOf('uint8_t snakeCell(')); +const dispatchAuth = firmware.slice(firmware.indexOf('bool authenticate()'),firmware.indexOf('#include "src/SnakeService.h"')); assert.match(dispatchAuth,/!webAccess.peerAllowed\(server.request\(\)\)/, 'Recheck the actual async continuation at loop dispatch before hardware access'); const probe = firmware.slice(firmware.indexOf('server.on("/api/avr/probe"'),firmware.indexOf('server.on("/api/avr/backup"')); diff --git a/tests/peer_relay_probe_test.mjs b/tests/peer_relay_probe_test.mjs new file mode 100644 index 00000000..644bc881 --- /dev/null +++ b/tests/peer_relay_probe_test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import {relayReadEnvelope} from '../tools/check-peer-relay.mjs'; +const input={id:'10000000000000000000000000000000',hostname:'keycontroller.local',board:'esp32s3-b81f3fbb0684',path:'/api/peer/info'}; +assert.deepEqual(relayReadEnvelope(input),{request_id:input.id,hostname:input.hostname,board_id:input.board,boot_id:'',method:'GET',path:input.path}); +assert.equal(relayReadEnvelope({...input,address:'192.168.1.201'}).address,'192.168.1.201'); +for(const address of ['8.8.8.8','169.254.169.254','127.0.0.1','192.168.1.201:80','192.168.01.2','192.168.1.255','192.168.1.0','192.168.1.256'])assert.throws(()=>relayReadEnvelope({...input,address})); +assert.throws(()=>relayReadEnvelope({...input,path:'/api/command'})); +assert.throws(()=>relayReadEnvelope({...input,path:'/api/status'})); +assert.throws(()=>relayReadEnvelope({...input,hostname:'http://evil.example'})); +assert.equal(relayReadEnvelope({...input,path:'/api/status',boot:input.id}).method,'GET'); +console.log('Read-only peer relay probe hints, identity and route limits passed'); diff --git a/tests/peer_relay_stubs/Arduino.h b/tests/peer_relay_stubs/Arduino.h new file mode 100644 index 00000000..33cc732a --- /dev/null +++ b/tests/peer_relay_stubs/Arduino.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#define F(value) value +inline uint32_t testNow=100; +inline unsigned stringNullReleases=0; +inline uint32_t millis(){return testNow;} +templateT min(T a,T b){return std::min(a,b);} +class String:public std::string { + public: + using std::string::string; + String()=default;String(const std::string &s):std::string(s){} + String& operator=(const char *s){if(s)assign(s);else{std::string{}.swap(*this);++stringNullReleases;}return *this;} + bool isEmpty()const{return empty();} + bool startsWith(const char *s)const{return rfind(s,0)==0;} + int indexOf(char c,size_t at=0)const{const auto n=find(c,at);return n==npos?-1:int(n);} + bool endsWith(const char *s)const{const size_t n=strlen(s);return size()>=n&&compare(size()-n,n,s)==0;} + String substring(size_t start,size_t end=std::string::npos)const{return substr(start,end==std::string::npos?end:end-start);} + bool reserve(size_t n){std::string::reserve(n);return true;} + bool concat(const char *s,size_t n){append(s,n);return true;} + size_t write(uint8_t c){push_back(char(c));return 1;} + size_t write(const uint8_t *p,size_t n){append(reinterpret_cast(p),n);return n;} + void toCharArray(char *out,size_t n)const{if(n){strncpy(out,c_str(),n-1);out[n-1]=0;}} +}; +using TaskHandle_t=void*; +inline void (*testWorker)(void*)=nullptr;inline void *testWorkerArg=nullptr; +inline unsigned taskCreates=0,taskNotifications=0;inline bool workerTaken=false,taskCreationFails=false; +inline bool simulateStackAllocation=false;extern size_t freeInternal,largestInternal; +constexpr int pdPASS=1,pdTRUE=1;constexpr uint32_t portMAX_DELAY=UINT32_MAX; +inline int xTaskCreate(void (*fn)(void*),const char*,unsigned stack,void *arg,int,TaskHandle_t *handle){ + if(taskCreationFails)return 0; + ++taskCreates;testWorker=fn;testWorkerArg=arg;*handle=arg; + if(simulateStackAllocation){freeInternal-=stack;largestInternal-=stack;} + return pdPASS; +} +inline void xTaskNotifyGive(TaskHandle_t){++taskNotifications;} +inline unsigned ulTaskNotifyTake(int,uint32_t){if(workerTaken)throw std::runtime_error("fixture-idle");workerTaken=true;return 1;} +inline void vTaskDelay(unsigned ticks){testNow+=ticks;} +inline void runRelayWorker(){workerTaken=false;try{testWorker(testWorkerArg);}catch(const std::runtime_error &e){if(std::string(e.what())!="fixture-idle")throw;}} diff --git a/tests/peer_relay_stubs/FS.h b/tests/peer_relay_stubs/FS.h new file mode 100644 index 00000000..e75baf0d --- /dev/null +++ b/tests/peer_relay_stubs/FS.h @@ -0,0 +1,20 @@ +#pragma once +#include "Arduino.h" +#include +class File { + public: + std::shared_ptr data;size_t position=0;bool failWrite=false; + explicit operator bool()const{return bool(data);} + size_t size()const{return data?data->size():0;} + int read(){return data&&positionsize()?uint8_t((*data)[position++]):-1;} + size_t readBytes(char *out,size_t n){size_t got=0;for(;got> files;unsigned writes=0; + bool failWrite=false,failRename=false; + File open(const char *path,const char *mode){if(mode[0]=='w'){++writes;files[path]=std::make_shared();}return {files.count(path)?files[path]:nullptr,0,failWrite};} + bool rename(const char *from,const char *to){if(failRename||!files.count(from))return false;files[to]=files[from];files.erase(from);return true;} +};} diff --git a/tests/peer_relay_stubs/HTTPClient.h b/tests/peer_relay_stubs/HTTPClient.h new file mode 100644 index 00000000..cbb25453 --- /dev/null +++ b/tests/peer_relay_stubs/HTTPClient.h @@ -0,0 +1,35 @@ +#pragma once +#include "WiFi.h" +#include +#include +constexpr int HTTPC_DISABLE_FOLLOW_REDIRECTS=0; +constexpr int HTTPC_ERROR_CONNECTION_REFUSED=-1,HTTPC_ERROR_NOT_CONNECTED=-4,HTTPC_ERROR_CONNECTION_LOST=-5; +struct TestReply {int code=200;String body,board="esp32s3-b81f3fbb0684",boot="1234567890abcdef1234567890abcdef";uint32_t delay=15;}; +struct TestRequest {String url,method,body,address;std::map headers;}; +inline std::deque replies;inline std::vector requests; +struct TestHttpStream { + String body;size_t cursor=0; + size_t available()const{return body.size()-cursor;} + int read(uint8_t *out,size_t n){n=std::min(n,available());memcpy(out,body.data()+cursor,n);cursor+=n;return int(n);} +}; +class HTTPClient { + public: + void setConnectTimeout(unsigned n){assert(n<=1000);}void setTimeout(unsigned n){assert(n<=1000);} + void setFollowRedirects(int n){assert(n==0);}void setReuse(bool yes){assert(!yes);} + bool begin(NetworkClient &client,const String &url){request_.url=url;client_=&client;return true;} + void setUserAgent(const String &value){request_.headers["User-Agent"]=value;} + void addHeader(const String &key,const String &value){if(key!="Host"&&key!="User-Agent")request_.headers[key]=value;} + void collectHeaders(const char **,unsigned){} + int GET(){return dispatch("GET","");}int POST(const String &body){return dispatch("POST",body);} + String header(const String &key){return key=="X-Pakar-Board"?reply_.board:key=="X-Pakar-Boot"?reply_.boot:String();} + int getSize()const{return int(reply_.body.size());}TestHttpStream *getStreamPtr(){return &stream_;} + bool connected()const{return stream_.available()>0;}void end(){} + private: + TestRequest request_;TestReply reply_;TestHttpStream stream_;NetworkClient *client_=nullptr; + int dispatch(const String &method,const String &body){ + const String host=request_.url.substring(7,request_.url.find('/',7)); + client_->connect(host.c_str(),80,700);request_.address=physicalAddress;request_.headers["Host"]=host; + request_.method=method;request_.body=body;requests.push_back(request_); + assert(!replies.empty());reply_=replies.front();replies.pop_front();testNow+=reply_.delay;stream_.body=reply_.body;return reply_.code; + } +}; diff --git a/tests/peer_relay_stubs/WiFi.h b/tests/peer_relay_stubs/WiFi.h new file mode 100644 index 00000000..5fd1b6d3 --- /dev/null +++ b/tests/peer_relay_stubs/WiFi.h @@ -0,0 +1,27 @@ +#pragma once +#include "Arduino.h" +enum IPType {IPv4,IPv6}; +class IPAddress { + public: + IPAddress()=default; + explicit IPAddress(uint32_t v):bytes_{uint8_t(v),uint8_t(v>>8),uint8_t(v>>16),uint8_t(v>>24)}{} + IPAddress(uint8_t a,uint8_t b,uint8_t c,uint8_t d):bytes_{a,b,c,d}{} + bool fromString(const String &s){unsigned a,b,c,d;char extra; + if(sscanf(s.c_str(),"%u.%u.%u.%u%c",&a,&b,&c,&d,&extra)!=4||a>255||b>255||c>255||d>255)return false; + bytes_[0]=a;bytes_[1]=b;bytes_[2]=c;bytes_[3]=d;return true; + } + String toString()const{char out[16];snprintf(out,sizeof(out),"%u.%u.%u.%u",bytes_[0],bytes_[1],bytes_[2],bytes_[3]);return out;} + uint8_t operator[](unsigned i)const{return bytes_[i];}IPType type()const{return IPv4;} + bool operator==(const IPAddress &other)const{return !memcmp(bytes_,other.bytes_,4);} + bool operator!=(const IPAddress &other)const{return !(*this==other);} + private:uint8_t bytes_[4]{}; +}; +struct TestWiFi {IPAddress localIP()const{return {192,168,1,218};}IPAddress softAPIP()const{return {192,168,4,1};}}; +inline TestWiFi WiFi; +inline String physicalAddress; +struct NetworkClient { + virtual ~NetworkClient()=default; + virtual int connect(const char*,uint16_t){throw std::runtime_error("unexpected DNS dial");} + virtual int connect(const char*,uint16_t,int32_t){throw std::runtime_error("unexpected DNS dial");} + virtual int connect(IPAddress ip,uint16_t,int32_t){physicalAddress=ip.toString();return 1;} +}; diff --git a/tests/peer_relay_stubs/esp_heap_caps.h b/tests/peer_relay_stubs/esp_heap_caps.h new file mode 100644 index 00000000..cb21b6b3 --- /dev/null +++ b/tests/peer_relay_stubs/esp_heap_caps.h @@ -0,0 +1,7 @@ +#pragma once +#include +constexpr int MALLOC_CAP_INTERNAL=1,MALLOC_CAP_8BIT=2; +inline size_t freeInternal=100000,largestInternal=40000; +inline size_t heap_caps_get_free_size(int){return freeInternal;} +inline size_t heap_caps_get_largest_free_block(int){return largestInternal;} +inline size_t heap_caps_get_minimum_free_size(int){return 216;} diff --git a/tests/peer_relay_stubs/lwip/dns.h b/tests/peer_relay_stubs/lwip/dns.h new file mode 100644 index 00000000..eebbe43e --- /dev/null +++ b/tests/peer_relay_stubs/lwip/dns.h @@ -0,0 +1,19 @@ +#pragma once +#include "Arduino.h" +using err_t=int;struct ip_addr_t{uint8_t bytes[4]{192,168,1,201};bool v4=true;}; +constexpr int ERR_OK=0,ERR_INPROGRESS=-5,LWIP_DNS_ADDRTYPE_IPV4=0; +#define IP_IS_V4(ip) ((ip)->v4) +#define ip_2_ip4(ip) (ip) +#define ip4_addr1(ip) ((ip)->bytes[0]) +#define ip4_addr2(ip) ((ip)->bytes[1]) +#define ip4_addr3(ip) ((ip)->bytes[2]) +#define ip4_addr4(ip) ((ip)->bytes[3]) +inline bool dnsDeferred=false,dnsFails=false; +inline void (*dnsCallback)(const char*,const ip_addr_t*,void*)=nullptr; +inline void *dnsArg=nullptr;inline String dnsHost; +inline err_t dns_gethostbyname_addrtype(const char *host,ip_addr_t *out,void (*fn)(const char*,const ip_addr_t*,void*),void *arg,int){ + dnsHost=host;dnsCallback=fn;dnsArg=arg; + if(dnsDeferred)return ERR_INPROGRESS; + if(dnsFails)return -1; + *out={};return ERR_OK; +} diff --git a/tests/peer_relay_stubs/lwip/tcpip.h b/tests/peer_relay_stubs/lwip/tcpip.h new file mode 100644 index 00000000..23e241de --- /dev/null +++ b/tests/peer_relay_stubs/lwip/tcpip.h @@ -0,0 +1,3 @@ +#pragma once +#include "dns.h" +inline err_t tcpip_callback(void (*fn)(void*),void *arg){fn(arg);return ERR_OK;} diff --git a/tests/peer_relay_stubs/mbedtls/sha256.h b/tests/peer_relay_stubs/mbedtls/sha256.h new file mode 100644 index 00000000..cff27ac6 --- /dev/null +++ b/tests/peer_relay_stubs/mbedtls/sha256.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +// Non-cryptographic fixture stand-in; SDK mbedTLS supplies production SHA256. +// Distinct raw requests must still have distinct fixture fingerprints. +inline int mbedtls_sha256(const unsigned char *raw,size_t n,unsigned char out[32],int){ + memset(out,0,32);for(size_t i=0;iaddr=mdnsAddress;return mdnsAddress?0:-1; +} diff --git a/tests/peer_relay_test.cpp b/tests/peer_relay_test.cpp new file mode 100644 index 00000000..0c64449f --- /dev/null +++ b/tests/peer_relay_test.cpp @@ -0,0 +1,140 @@ +#define ARDUINO_ARCH_ESP32 +#include "../firmware/esp/src/PeerRelay.h" +#include +#include + +const String board="esp32s3-b81f3fbb0684",boot="1234567890abcdef1234567890abcdef",self="esp32s3-907069eaaf34"; +unsigned nextId=1; +String id(){char value[33];snprintf(value,sizeof(value),"%032x",nextId++);return value;} +JsonDocument json(const String &raw){JsonDocument d;assert(!deserializeJson(d,raw));return d;} +String encode(const JsonDocument &d){String raw;serializeJson(d,raw);return raw;} +String envelope(const char *path="/api/peer/info",const char *hint="",bool command=false){ + JsonDocument d;d["request_id"]=id();d["hostname"]="keycontroller.local";d["board_id"]=board; + d["boot_id"]=String(path)=="/api/peer/info"?"":boot;d["method"]=command?"POST":"GET";d["path"]=path; + if(*hint)d["address"]=hint; + if(command){JsonDocument p;p["type"]="command";p["request_id"]=d["request_id"];p["board_id"]=board;p["boot_id"]=boot;p["expires_at_ms"]=uint64_t(999999);d["packet"]=encode(p);} + return encode(d); +} +String info(){JsonDocument d;d["application"]="PakarController";d["version"]="0.2.8";d["board_id"]=board;d["boot_id"]=boot;return encode(d);} +void add(const String &body,int code=200){TestReply reply;reply.body=body;reply.code=code;replies.push_back(reply);} +JsonDocument run(PeerRelay &relay,const String &raw){ + const unsigned released=stringNullReleases;auto receipt=json(relay.submit(raw,self));assert(receipt["accepted"]==true); + assert(stringNullReleases==released+1&&receipt["response"].is()&&receipt["response"]==""); + runRelayWorker();assert(replies.empty());return json(relay.snapshot()); +} +void clean(){requests.clear();replies.clear();mdnsCalls=0;mdnsAddress=0xc901a8c0;freeInternal=100000;largestInternal=40000;} +void stickyAndPersistent(){ + clean();fs::FS fs;PeerRelay relay;relay.begin(fs,true);const unsigned priorTasks=taskCreates; + add(info());auto first=run(relay,envelope());assert(first["response_verified"]==true&&mdnsCalls==1); + assert(mdnsLabel=="keycontroller"&&mdnsBudget==900&&requests.size()==1); + assert(fs.writes==0); // Never silently persist discovery or observation. + mdnsAddress=0;add("{\"board_id\":\"esp32s3-b81f3fbb0684\",\"boot_id\":\"1234567890abcdef1234567890abcdef\",\"large\":[1,2,3]}"); + auto second=run(relay,envelope("/api/status")); + assert(second["response_verified"]==true&&second["route_source"]=="verified-cache"); + assert(mdnsCalls==1&&requests.size()==2&&requests.back().url=="http://keycontroller.local/api/status"); + assert(requests.back().address=="192.168.1.201"); + assert(requests.back().headers["Host"]=="keycontroller.local"); + assert(requests.back().headers["User-Agent"].find("PakarController/")==0); + assert(taskCreates==priorTasks+1); // One worker, not one task allocation/read. + const String save="{\"action\":\"save\",\"board_id\":\"esp32s3-b81f3fbb0684\",\"hostname\":\"keycontroller.local\"}"; + assert(json(relay.saveRoute(save))["accepted"]==true&&fs.writes==1); + assert(json(relay.saveRoute(save))["accepted"]==true&&fs.writes==1); + auto directory=json(relay.directory());assert(directory["routes"].size()==1&&directory["routes"][0]["saved"]==true); + PeerRelay restored;restored.begin(fs,true); + assert(json(restored.directory())["routes"][0]["verified_this_boot"]==false); + add(info());add("{}");auto saved=run(restored,envelope("/api/status")); + assert(saved["response_verified"]==true&&saved["route_source"]=="saved-route"); + assert(mdnsCalls==1); // Saved hint is reverified, not rediscovered/auto-trusted. +} +void hintsAndIdentity(){ + clean();PeerRelay relay;fs::FS fs;relay.begin(fs,true);mdnsAddress=0; + add(info());auto hinted=run(relay,envelope("/api/peer/info","192.168.1.201")); + assert(hinted["response_verified"]==true&&hinted["route_source"]=="address-hint"&&mdnsCalls==0); + assert(json(relay.submit(envelope("/api/status","169.254.169.254"),self))["error"]=="invalid_peer_address"); + TestReply wrong;wrong.body=info();wrong.board="other-board";replies.push_back(wrong); + auto failed=run(relay,envelope("/api/status"));assert(failed["response_verified"]==false&&requests.size()==2&&mdnsCalls==0); + // A stale boot/header cannot promote another address or trigger discovery. + assert(json(relay.saveRoute("{\"action\":\"save\",\"board_id\":\"missing\",\"hostname\":\"unknown.local\"}"))["error"]=="peer_route_not_verified"); + PeerRelay selfRelay;auto rejected=run(selfRelay,envelope("/api/peer/info","192.168.1.218")); + assert(rejected["error"]=="peer_lan_resolution_failed"); +} +void connectRecoveryAndNoReplay(){ + clean();PeerRelay relay;add(info());run(relay,envelope()); + const unsigned before=requests.size();mdnsAddress=0xca01a8c0; // Changed to .202. + add("",-1);add(info());add("{}");auto recovered=run(relay,envelope("/api/status")); + assert(recovered["response_verified"]==true&&recovered["address"]=="192.168.1.202"); + assert(requests.size()==before+3&&mdnsCalls==2); + const String raw=envelope("/api/command","",true);add(info());add("",-1); + auto uncertain=run(relay,raw);assert(uncertain["error"]=="command_outcome_uncertain_do_not_replay"); + assert(uncertain["command_dispatched"]==true&&requests.back().method=="POST"); + const unsigned sent=requests.size();auto repeated=json(relay.submit(raw,self)); + assert(repeated["error"]=="command_outcome_uncertain_do_not_replay"&&requests.size()==sent&&!relay.busy()); + auto changed=json(raw);changed["address"]="192.168.1.203"; + assert(json(relay.submit(encode(changed),self))["error"]=="request_id_conflict"); + // A stale/malformed receipt never becomes accepted execution. + add(info());add("{\"type\":\"command-result\",\"accepted\":true}"); + auto malformed=run(relay,envelope("/api/command","",true));assert(malformed["error"]=="uncorrelated_peer_receipt"); +} +void memoryAndExpiry(){ + clean();PeerRelay relay;freeInternal=69999; + assert(json(relay.submit(envelope(),self))["error"]=="peer_relay_low_memory"); + freeInternal=100000;largestInternal=12000; + assert(json(relay.submit(envelope(),self))["error"]=="peer_relay_low_memory"); + largestInternal=31732; // Actual incident's largest block is sufficient. + for(unsigned i=0;i<8;++i){add(info());assert(run(relay,envelope())["response_verified"]==true);} + assert(json(relay.submit(envelope(),self))["error"]=="peer_receipts_full"); + testNow+=60000;add(info());assert(run(relay,envelope())["response_verified"]==true); +} +void errorsAndLargeReceipt(){ + clean();PeerRelay relay;add(info());run(relay,envelope()); + JsonDocument status;status["board_id"]=board;status["boot_id"]=boot; + status["payload"]=String(18000,'x')+"\"\\\n";const String large=encode(status);add(large); + auto complete=run(relay,envelope("/api/status"));assert(complete["response"].as()==large); + auto usage=json(relay.directory());assert(usage["memory"]["internal_minimum_free_bytes"]==216); + assert(usage["memory"]["internal_free_bytes"]==100000&&usage["memory"]["minimum_is_historical"]==true); + const unsigned lookups=mdnsCalls,sent=requests.size(); + add("{}",401);auto auth=run(relay,envelope("/api/status")); + assert(auth["error"]=="peer_authentication_required"&&mdnsCalls==lookups&&requests.size()==sent+1); + TestReply stale;stale.body=info();stale.boot="abcdef1234567890abcdef1234567890";replies.push_back(stale); + auto failed=run(relay,envelope("/api/command","",true)); + assert(failed["response_verified"]==false&&failed["command_dispatched"]==false&&requests.back().method=="GET"); + assert(mdnsCalls==lookups); // No stale-boot retargeting or POST. + add("",-11);auto timeout=run(relay,envelope("/api/status")); + assert(timeout["response_verified"]==false&&mdnsCalls==lookups); // HTTP timeout isn't failed DNS. +} +void resolverLateCallback(){ + PeerLanResolver resolver;IPAddress ip;dnsDeferred=true;const auto start=millis(); + assert(!resolver.lookup("keycontroller",ip)&&uint32_t(millis()-start)==900); + assert(!resolver.lookup("another-host",ip)&&dnsHost=="keycontroller"); // No overwrite/UAF. + ip_addr_t late{};dnsCallback("keycontroller",&late,dnsArg);dnsDeferred=false; + assert(resolver.lookup("another-host",ip)&&dnsHost=="another-host"&&ip.toString()=="192.168.1.201"); +} +void changedHintDuringDnsOutage(){ + clean();PeerRelay relay;add(info());run(relay,envelope());mdnsAddress=0; + add("",-1);add(info());add("{}"); + auto next=run(relay,envelope("/api/status","192.168.1.203")); + assert(next["response_verified"]==true&&next["route_source"]=="address-hint"&&next["address"]=="192.168.1.203"); + assert(requests[requests.size()-2].url=="http://keycontroller.local/api/peer/info"); + assert(requests.back().address=="192.168.1.203"&&requests.back().method=="GET"); +} +void stackFragmentationDoesNotLockOutReads(){ + clean();largestInternal=31732;simulateStackAllocation=true; + PeerRelay relay;add(info());assert(run(relay,envelope())["response_verified"]==true); + assert(largestInternal==21492);add("{}");assert(run(relay,envelope("/api/status"))["response_verified"]==true); + largestInternal=4095;assert(json(relay.submit(envelope(),self))["error"]=="peer_relay_low_memory"); + simulateStackAllocation=false; +} +void persistenceFaults(){ + fs::FS fs;PeerRouteStore store;store.begin(fs,true); + PeerRouteStore::Route route{"keycontroller.local",board,"192.168.1.201"}; + assert(store.update(route,false)==nullptr); + const String saved=*fs.files["/peer-routes.json"];fs.failRename=true;route.address="192.168.1.202"; + assert(String(store.update(route,false))=="route_publish_failed"); + assert(*fs.files["/peer-routes.json"]==saved&&store.at(0).address=="192.168.1.201"); + fs.failRename=false;assert(store.update(route,false)==nullptr); + PeerRouteStore restored;restored.begin(fs,true);assert(restored.at(0).address==route.address); + *fs.files["/peer-routes.json"]="{\"routes\":[{\"hostname\":\"keycontroller.local\",\"board_id\":\"peer\",\"address\":\"8.8.8.8\"}]}"; + PeerRouteStore corrupt;corrupt.begin(fs,true);assert(corrupt.at(0).board.isEmpty()); + PeerRouteStore unavailable;unavailable.begin(fs,false);assert(String(unavailable.update(route,false))=="volume_unavailable"); +} +int main(){stickyAndPersistent();hintsAndIdentity();connectRecoveryAndNoReplay();memoryAndExpiry();errorsAndLargeReceipt();resolverLateCallback();changedHintDuringDnsOutage();stackFragmentationDoesNotLockOutReads();persistenceFaults();puts("Actual ESP peer relay/cache/route-store/DNS fixtures passed; not LAN or physical acceptance");} diff --git a/tests/peer_route_policy_test.cpp b/tests/peer_route_policy_test.cpp index 7491db29..299408a0 100644 --- a/tests/peer_route_policy_test.cpp +++ b/tests/peer_route_policy_test.cpp @@ -6,6 +6,8 @@ int main() { for(const char *s:{"localhost","127.0.0.1","a/../x","a:80","a@b","a\r\nx","-a","a-","a..b","a."})assert(!hostname(s)); assert(privateAddress(192,168,4,1));assert(privateAddress(10,1,0,2)); assert(!privateAddress(127,0,0,1));assert(!privateAddress(169,254,169,254));assert(!privateAddress(224,0,0,1));assert(!privateAddress(192,168,1,255)); + for(const char *s:{"192.168.1.201","10.0.0.2","172.16.1.99"})assert(privateIpv4(s)); + for(const char *s:{"192.168.1.201:80","192.168.01.2","192.168.1.256","192.168.1.0","192.168.1.255","127.0.0.1","169.254.169.254","8.8.8.8","1.2.3.4.5","192.168.1.2/",""})assert(!privateIpv4(s)); assert(readPath("/api/status"));assert(!readPath("/api/peers/request"));assert(!readPath("/api/avr/probe"));assert(!readPath("/firmware/download")); assert(hexId("1234567890abcdef1234567890abcdef"));assert(!hexId("00000000000000000000000000000000")); assert(boardId("esp32s3-b81f3fbb0684"));assert(!boardId("a\r\nHost:evil")); diff --git a/tests/pixel_group_routes_test.mjs b/tests/pixel_group_routes_test.mjs index 9fdb4e48..121d43a7 100644 --- a/tests/pixel_group_routes_test.mjs +++ b/tests/pixel_group_routes_test.mjs @@ -21,12 +21,14 @@ assert(binding.includes('grid.groupLayout()')); assert(binding.includes('d["count"]=action.count')); assert(binding.includes('d["group_layout_revision"]=action.groupLayoutRevision')); assert(!binding.includes('action.group?6:1'),'Device TUI uses the same revision-fenced physical spans'); -// General file access is a read-only /user namespace, not an alternate settings +// Managed public-file writes are supported, but are not an alternate settings // writer. Both durable and staged topology paths remain behind validated APIs. const userPath=usb.slice(usb.indexOf('bool maintenanceUserPath('),usb.indexOf('String maintenanceApi(')); assert(userPath.includes('!path.startsWith("/user/")')); assert(userPath.includes('path.indexOf("..")<0')); -assert(usb.includes('reply["file_write"]=false')); +assert(usb.includes('reply["file_write"]=true')); +assert(usb.includes('reply["private_file_access"]=false')); +assert(usb.includes('if(method=="POST"&&path=="/api/storage/files")return userFileRequest(body)')); assert(usb.includes('if(!maintenanceUserPath(filename))return maintenanceApiError("protected_or_invalid_path")')); const artifacts=read('firmware/esp/src/ArtifactDownload.h'); assert(!artifacts.includes('pixel-groups')); diff --git a/tests/portable_shell_source_test.mjs b/tests/portable_shell_source_test.mjs new file mode 100644 index 00000000..e5f17049 --- /dev/null +++ b/tests/portable_shell_source_test.mjs @@ -0,0 +1,25 @@ +import {readFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import assert from 'node:assert/strict'; +const read=path=>readFileSync(new URL('../'+path,import.meta.url),'utf8').replaceAll('\r\n','\n'); +const source=JSON.parse(read('third_party/portable-shell/source.json')); +assert.equal(source.revision,'67c5dcb8a63f4055cbdaf02e9ff271c53b46410d'); +assert.equal(source.files.length,17); +const ledger=JSON.parse(read('docs/file-comparison-evidence.json')).files.filter(f=>f.origin==='portable-shell'); +assert.equal(ledger.length,17); +for(const file of source.files){ + const entry=ledger.find(e=>e.path===file.path); + assert.equal(entry?.sha256,file.sha256);assert.equal(entry.source_read,'complete'); + const text=file.snapshot?read('third_party/portable-shell/'+file.snapshot):'.'; + assert.equal(Buffer.byteLength(text),file.bytes,file.path); + assert.equal(createHash('sha256').update(text).digest('hex'),file.sha256,file.path); + assert.ok(entry.omissions.length||entry.preserved.length,file.path); +} +// The reference C library/examples are deliberately not an executable firmware +// dependency; production imports the bounded adaptation through its real editor. +assert.ok(read('firmware/esp/src/TelnetShell.h').includes('#include "PortableShellCore.h"')); +const service=read('firmware/esp/src/ShellService.h'); +for(const text of ['PakarShell::findCommand(verb.c_str())','verb=command->canonical', + 'PakarShell::renderHelp(','PakarShell::toneCommand(args.c_str(),tone)','d["command"]="buzzer"','if(!s.authenticated)return;', + 's.editor.complete(start,shellPropertyCount'])assert.ok(service.includes(text),text); +console.log('All 17 pinned portable-shell files accounted for; live integration wiring checked.'); diff --git a/tests/portable_shell_test.cpp b/tests/portable_shell_test.cpp new file mode 100644 index 00000000..7defdf2d --- /dev/null +++ b/tests/portable_shell_test.cpp @@ -0,0 +1,92 @@ +// Adapted donor test_shell.c scenarios: independent contexts, command lookup, +// built-ins, editing and callback output. No network or production board access. +#include "TelnetShell.h" +#include "ShellToneCommand.h" +#include +#include +#include +int main(){ + using namespace PakarShell; + std::string output; + auto write=[&](const uint8_t* p,size_t n){output.append(reinterpret_cast(p),n);}; + for(size_t i=0;iusage && canonical->readOnly==commands[i].readOnly); + for(size_t j=0;jcanonical)=="tui"); + assert(std::string(findCommand("cls")->canonical)=="clear"); + assert(!findCommand("set")->readOnly&&!findCommand("reset")->readOnly); + assert(findCommand("beep")&&!findCommand("beep")->readOnly); + ToneCommand tone; + assert(toneCommand("",tone)&&tone.frequency==1000&&tone.duration==100); + assert(toneCommand("440 250",tone)&&tone.frequency==440&&tone.duration==250); + assert(toneCommand("12000 5000",tone)&&tone.frequency==12000&&tone.duration==5000); + for(const char* bad:{"0","19","12001","-1","440 0","440 5001","440 1 extra","440 nope","440 ","1e3","9999999"})assert(!toneCommand(bad,tone)); + renderHelp(write,true,true,true,80); + assert(output.find("Usage")!=std::string::npos&&output.find("Sound & display")!=std::string::npos); + assert(output.find(" : ")==std::string::npos&&output.find("cell ID")==std::string::npos); + assert(output.find("\xe2\x94\x80")!=std::string::npos&&output.find("\x1b[90m")!=std::string::npos); + output.clear();renderHelp(write,false,false,false,40); + assert(output.find('\x1b')==std::string::npos&&output.find("game press")==std::string::npos); + output.clear(); + Terminal::clear(write);Terminal::clearLine(write);Terminal::cursorLeft(write,12); + Terminal::color(write,32,40);Terminal::reset(write); + assert(output=="\x1b[H\x1b[2J\r\x1b[2K\x1b[12D\x1b[32;40m\x1b[0m"); + output.clear();Terminal::cursorLeft(write,513);Terminal::color(write,123,40);Terminal::color(write,32,99);Terminal::reset(write,false); + assert(output.empty());Terminal::clear(write,false);Terminal::cursorLeft(write,2,false);assert(output=="\r\n\b\b"); + LineEditor a,b; + auto feed=[&](LineEditor& editor,const std::string& text){for(unsigned char c:text)editor.feed(c,write);}; + auto candidates=[](size_t i){return commands[i].name;}; + feed(a,"sta");output.clear();a.complete(0,commandCount,candidates,write); + assert(std::string(a.line())=="sta"&&output.empty()); // hidden password + assert(a.feed('\t',write)==LineEditor::None); + a.clear();a.setEcho(true);feed(a,"sta");output.clear(); + assert(a.feed('\t',write)==LineEditor::Complete); + a.complete(0,commandCount,candidates,write); + assert(std::string(a.line())=="status "&&a.historySize()==0&&b.line()[0]==0); + a.clear();feed(a,"cell");a.complete(0,commandCount,candidates,write); + assert(std::string(a.line())=="cell"); // ambiguous cell/cells: do not execute + assert(output.find("cells")!=std::string::npos); + a.clear();feed(a,"co");a.complete(0,commandCount,candidates,write);assert(std::string(a.line())=="color "); + a.clear();feed(a,"sta\x1b[D");a.complete(0,commandCount,candidates,write);assert(std::string(a.line())=="sta"&&a.cursor()==2); + // Fragmented escape sequences still belong to the editor, never completion. + a.clear();feed(a,"sta\x1b[");a.complete(0,commandCount,candidates,write);assert(std::string(a.line())=="sta"); + a.feed('D',write);assert(a.cursor()==2); + const char* props[]={"network.mode","network.ssid",nullptr}; + auto property=[&](size_t i){return props[i];}; + a.clear();feed(a,"set net");a.complete(4,3,property,write);assert(std::string(a.line())=="set network."); + feed(a,"mo");a.complete(4,3,property,write);assert(std::string(a.line())=="set network.mode "); + a.clear();feed(a,"unknown");output.clear();a.complete(0,commandCount,candidates,write);assert(output=="\a"); + a.clear();feed(a,std::string(LineEditor::kCapacity,'x')); + const auto full=std::string(a.line());a.complete(0,1,[&](size_t){return full.c_str();},write); + assert(std::string(a.line())==full&&a.cursor()==LineEditor::kCapacity); + // All sessions share descriptors, not mutable history/input or IO buffers. + b.setEcho(true);feed(b,"edi");b.complete(0,commandCount,candidates,write); + assert(std::string(b.line())=="edit "&&std::string(a.line())==full); + a.clear();feed(a,"STA");a.complete(0,commandCount,candidates,write);assert(std::string(a.line())=="status "); + a.clear();a.setUpper(true);output.clear();feed(a,"echo MixedCase"); + assert(output=="ECHO MIXEDCASE"&&std::string(a.line())=="echo MixedCase"); + output.clear();a.redraw(write);assert(output.find("ECHO MIXEDCASE")!=std::string::npos); + assert(a.remember(true));a.clear();feed(a,"\x1b[A");assert(std::string(a.line())=="echo MixedCase"); + a.clear();a.setEcho(false);output.clear();feed(a,"Sensitive");assert(output.empty()); + a.reset();a.setEcho(true);output.clear();feed(a,"lower");assert(output=="lower"); + // Ghost suggestions cannot execute, leak arguments, alter history or survive + // edits. Fragmented CSI/SS3 Right and both End forms explicitly accept them. + for(const char* key:{"\x1b[C","\x1bOC","\x1b[F","\x1b[4~","\x1b[8~"}){ + a.clear();feed(a,"sta");output.clear();a.suggest(0,commandCount,candidates,write); + assert(std::string(a.line())=="sta"&&output=="\x1b[90mtus\x1b[0m\x1b[3D"); + feed(a,key);assert(std::string(a.line())=="status "&&a.cursor()==7); + } + a.clear();feed(a,"sta");a.suggest(0,commandCount,candidates,write);feed(a,"\x1b[D"); + assert(std::string(a.line())=="sta"&&a.cursor()==2); + a.clear();feed(a,"sta");a.suggest(0,commandCount,candidates,write);feed(a,"x\x1b[C");assert(std::string(a.line())=="stax"); + a.clear();a.setEcho(false);feed(a,"sta");output.clear();a.suggest(0,commandCount,candidates,write);feed(a,"\x1b[C");assert(output.empty()&&std::string(a.line())=="sta"); + a.clear();a.setEcho(true);a.setPrompt("plain",false);feed(a,"sta");output.clear();a.suggest(0,commandCount,candidates,write);assert(output.empty()); + std::cout<<"Portable-shell catalogue, VT100 and bounded completion passed.\n"; +} diff --git a/tests/resource_report_test.mjs b/tests/resource_report_test.mjs new file mode 100644 index 00000000..2eec790c --- /dev/null +++ b/tests/resource_report_test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import {avrStaticRam,otaCapacity,metric,compareBudgets,liveResources,observationStatus,fetchResources,buildMarkdown,collectBuild} from '../tools/resource-report.mjs'; +import {mkdirSync,writeFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import {resolve} from 'node:path'; +const elf=Buffer.alloc(172);elf.write('7f454c46',0,'hex');elf[4]=1;elf[5]=1;elf.writeUInt16LE(83,18);elf.writeUInt32LE(52,32);elf.writeUInt16LE(40,46);elf.writeUInt16LE(3,48); +for(const [i,address,size] of [[0,0x800100,100],[1,0x800164,200],[2,0x810000,50]]){const at=52+40*i;elf.writeUInt32LE(2,at+8);elf.writeUInt32LE(address,at+12);elf.writeUInt32LE(size,at+20);} +assert.equal(avrStaticRam(elf),300);assert.throws(()=>avrStaticRam(elf.subarray(0,60)));elf[5]=2;assert.throws(()=>avrStaticRam(elf)); +const pt=Buffer.alloc(96,255);for(let i=0;i<2;i++){const at=i*32;pt.writeUInt16LE(0x50aa,at);pt[at+2]=0;pt[at+3]=0x10+i;pt.writeUInt32LE(3342336-i*4096,at+8);} +assert.equal(otaCapacity(pt),3338240);assert.throws(()=>otaCapacity(pt.subarray(0,32))); +assert.equal(metric(0,2048).used_percent,0);assert.throws(()=>metric(null,2048)); +const rows=[{target:'avr',profile:'same',metrics:{ram:metric(1800,2048)}}],policy={ram:{max_used_percent:90,max_growth_bytes:128}}; +const baseline={source_sha:'baseline',source_dirty:false,boards:[{target:'avr',profile:'same',metrics:{ram:metric(1600,2048)}}]}; +assert.match(compareBudgets(rows,baseline,policy).violations[0],/grew 200/); +assert.equal(compareBudgets(rows,{...baseline,source_dirty:true},policy).comparisons.length,0); +assert.throws(()=>compareBudgets(rows,null,{ram:{max_used_percent:null}})); +baseline.boards[0].profile='different';assert.equal(compareBudgets(rows,baseline,policy).comparisons.length,0); +assert.match(compareBudgets([{...rows[0],metrics:{ram:metric(2050,2048)}}],null,policy).violations[0],/physical/); +assert.match(buildMarkdown({source_sha:'abc',boards:rows,...compareBudgets(rows,null,policy),scope:'static only'}),/static only/); +const telemetry={type:'telemetry',product:'PakarController',board_id:'board',boot_id:'boot',esp:{internal_heap_free_bytes:74000,psram_bytes:8388608,free_psram_bytes:8300000,filesystem:{known:true,total_bytes:100,used_bytes:10,age_ms:90000}},avr:{online:true,telemetry_recent:false,free_sram_bytes:293}}; +const live=liveResources(telemetry,{observedAt:'2026-10-27T00:00:00Z'}); +assert.equal(live.esp.internal_heap_free_bytes,74000);assert.equal(live.avr.free_sram_bytes,null);assert.equal(live.avr.status,'stale');assert.equal(live.esp.filesystem.age_ms,90000); +assert.equal(observationStatus(live,Date.parse(live.observed_at)+15001),'stale');assert.equal(observationStatus(live,Date.parse(live.observed_at)+1),'observed'); +assert.throws(()=>liveResources({...telemetry,board_id:null})); +const result=await fetchResources('http://ledcontroller.local',async(url,path,options)=>{assert.equal(path,'/api/telemetry');assert.deepEqual(options.headers,{});return {status:200,body:Buffer.from(JSON.stringify(telemetry))};},{}); +assert.equal(result.board_id,'board');await assert.rejects(()=>fetchResources('http://ledcontroller.local',async()=>({status:302}),{}),/HTTP 302/); +const dir=fileURLToPath(new URL('../build/tests/resource-report',import.meta.url)); +const artifacts=[];elf[5]=1; +for(const target of ['avr','avr-busy-wire','avr-buffered','esp32s3-buffered']){ + const folder=resolve(dir,target);mkdirSync(folder,{recursive:true}); + const avr=target.startsWith('avr'),name=avr?'avr.ino.with_bootloader.bin':'esp32s3.ino.bin',bytes=Buffer.alloc(avr?32768:2048); + const sha=createHash('sha256').update(bytes).digest('hex');writeFileSync(resolve(folder,name),bytes); + artifacts.push({path:target+'/'+name,format:'raw',sha256:sha}); + writeFileSync(resolve(folder,'resource-build.json'),JSON.stringify({artifact_sha256:sha,fqbn:'fixture',version:'0.2.8',numeric_feature_flags:{PAKAR_NETWORK_ENABLED:1}})); + if(avr){writeFileSync(resolve(folder,'avr-image.json'),JSON.stringify({binary_sha256:sha,structural_validation:true,application_bytes:1000,boot_start:32384,profile:'fixture'}));writeFileSync(resolve(folder,'avr.ino.elf'),elf);} + else writeFileSync(resolve(folder,'esp32s3.ino.partitions.bin'),pt); +} +writeFileSync(resolve(dir,'firmware-report.json'),JSON.stringify({source_sha:'fixture',source_dirty:false,artifacts})); +const budgets={avr_flash:{max_used_percent:98,max_growth_bytes:2048},avr_static_ram:{max_used_percent:90,max_growth_bytes:128},esp_ota:{max_used_percent:95,max_growth_bytes:262144}}; +const collected=collectBuild(dir,budgets);assert.equal(collected.boards.length,4);assert.equal(collected.boards[0].build.version,'0.2.8');assert.equal(collected.violations.length,0); +writeFileSync(resolve(dir,'avr/resource-build.json'),JSON.stringify({artifact_sha256:'incorrect'}));assert.throws(()=>collectBuild(dir,budgets),/metadata/); +console.log('Resource reports: ELF/partition bounds, physical and growth budgets, missing values, stale AVR/filesystem and read-only fetching passed.'); diff --git a/tests/reuse_ledger_test.ps1 b/tests/reuse_ledger_test.ps1 index c13cc593..2f248f46 100644 --- a/tests/reuse_ledger_test.ps1 +++ b/tests/reuse_ledger_test.ps1 @@ -18,7 +18,7 @@ $ledger = Join-Path $scratch 'ledger.csv' Export-Csv -NoTypeInformation -LiteralPath $evidence $options = @{ PealayerRoot=$donor; PCControllerRoot=$donor; PuzzlesRoot=$donor; - PuzzleBoardRoot=$donor; ControllerBoardMiniRoot=$donor; Pu850Root=$donor; RayanLampRoot=$donor; Esp32Ws2811Root=$donor; + PuzzleBoardRoot=$donor; ControllerBoardMiniRoot=$donor; Pu850Root=$donor; RayanLampRoot=$donor; Esp32Ws2811Root=$donor; DrsSnakeRoot=$donor; DecisionsPath=$decisions; EvidencePath=$evidence; OutputPath=$ledger } function Assert-State([string]$origin, [string]$status) { @@ -31,6 +31,8 @@ try { Assert-State PCController adapted Assert-State RayanLamp screened Assert-State esp32WS2811 screened + $marker = Import-Csv -LiteralPath $ledger | Where-Object { $_.origin -eq 'portable-shell' -and $_.path -eq '_codeql_detected_source_root' } + if ($marker.review_status -ne 'screened') { throw 'Generated marker must not invent a manual review without evidence' } . (Join-Path $root 'tools/donor-files.ps1') foreach ($relative in @('node_modules','dist','vendor')) { New-Item -ItemType Directory -Path (Join-Path $donor $relative) -Force | Out-Null diff --git a/tests/s3_platform_wiring_test.mjs b/tests/s3_platform_wiring_test.mjs index 4de33d68..c8d815a4 100644 --- a/tests/s3_platform_wiring_test.mjs +++ b/tests/s3_platform_wiring_test.mjs @@ -71,7 +71,10 @@ assert.match(dnsLock, /if\(!alreadyHeld\) LOCK_TCPIP_CORE\(\);\s+__real_dns_clea const networkPath=read('NetworkPath.h'); const dnsRecovery=readFileSync(new URL('../firmware/esp32s3/src/DnsRecovery.h',import.meta.url),'utf8'); function checkDnsPath(main,path,recovery) { - assert.match(main, /void configureNtp\(\) \{\s+NetworkPath::serviceDns\(millis\(\), !ntpConfigured\);/); + const ntp = main.slice(main.indexOf('void configureNtp() {'), main.indexOf('bool applyStreamPacket(')); + const dns = ntp.indexOf('NetworkPath::serviceDns(millis(), !ntpConfigured);'); + const configured = ntp.indexOf('if (!PAKAR_NETWORK_ENABLED || ntpConfigured) return;'); + assert(dns >= 0 && configured > dns, 'DNS recovery runs before the configured-SNTP fast return, after any first-boot TCP/IP initialization'); assert.match(main, /if\(remoteFirmware.needsDownloadConnection\(\)\)\s+NetworkPath::serviceDns\(millis\(\),true\)/); assert.match(path, /if \(state\(\).selected == Interface::Wifi\)\s+PakarS3::serviceDhcpDns\(now, networkConfig.dhcpEnabled\(\), immediate\)/); assert.match(path, /#if PAKAR_S3_ETHERNET_ENABLED && PAKAR_S3_DNS_GATEWAY_FALLBACK/); diff --git a/tests/scp_client_test.mjs b/tests/scp_client_test.mjs new file mode 100644 index 00000000..b5e53a41 --- /dev/null +++ b/tests/scp_client_test.mjs @@ -0,0 +1,15 @@ +import {spawnSync} from 'node:child_process'; +import {mkdirSync,readFileSync} from 'node:fs'; +import {resolve} from 'node:path'; +import assert from 'node:assert/strict'; +const executable=resolve('build/native/scp_download_test.exe'); +const output=resolve('build/native/scp-client-fixture');mkdirSync(output,{recursive:true}); +for(const [name,size] of [['test.bin',513],['empty.bin',0]]){ + // -S uses the compiled production protocol fixture as the SSH byte pipe; + // no host, network connection, key, password or board is contacted. + const path=resolve(output,name); + const result=spawnSync('scp',['-O','-S',executable,`fixture:/user/${name}`,path],{encoding:'utf8',timeout:10000}); + assert.equal(result.status,0,result.error?.message||result.stderr); + assert.deepEqual(readFileSync(path),Buffer.from(Array.from({length:size},(_,i)=>i&255))); +} +console.log('Real OpenSSH SCP client accepted binary/empty production-protocol downloads over a stdio fixture; not an encrypted SSH/ESP session.'); diff --git a/tests/scp_download_test.cpp b/tests/scp_download_test.cpp new file mode 100644 index 00000000..f7ab2b63 --- /dev/null +++ b/tests/scp_download_test.cpp @@ -0,0 +1,50 @@ +#include "../firmware/esp32s3/src/ScpDownload.h" +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#endif +int main(int argc,char** argv){ + // OpenSSH -S fixture: production SCP source over stdio, not an SSH server. + // This independently checks real client interoperability, not encryption. + if(argc>1){ +#ifdef _WIN32 + _setmode(_fileno(stdin),_O_BINARY);_setmode(_fileno(stdout),_O_BINARY); +#endif + char name[65];if(!PakarS3::ScpDownload::command(argv[argc-1],name,sizeof(name)))return 1; + const uint32_t size=std::string(name)=="empty.bin"?0:513; + PakarS3::ScpDownload transfer;if(!transfer.start(name,size))return 1; + while(transfer.phase()!=PakarS3::ScpDownload::Done&&transfer.phase()!=PakarS3::ScpDownload::Failed){ + const auto phase=transfer.phase(); + if(phase==PakarS3::ScpDownload::ReadyAck||phase==PakarS3::ScpDownload::HeaderAck||phase==PakarS3::ScpDownload::FinalAck){ + char byte;if(!std::cin.get(byte))return 1;transfer.input(uint8_t(byte)); + }else transfer.service([](uint32_t offset,uint8_t* bytes,size_t count){for(size_t i=0;i(bytes),count);std::cout.flush();return bool(std::cout);}); + } + return transfer.phase()==PakarS3::ScpDownload::Done?0:1; + } + using PakarS3::ScpDownload;char name[65]; + assert(ScpDownload::command("scp -f /user/example.bin",name,sizeof(name))&&std::string(name)=="example.bin"); + for(const char* text:{"scp -t /user/a","scp -r -f /user/a","scp -f /settings.json","scp -f /user/../a","scp -f /user/.secret","scp -f /user/a;reset","scp -f /user/a\n","scp -f /user/a b","scp -f /user/a/b","scp -f /user/a*"})assert(!ScpDownload::command(text,name,sizeof(name))); + ScpDownload download;assert(download.start("test.bin",300)); + std::string wire;bool blocked=false;unsigned reads=0; + auto emit=[&](const uint8_t* bytes,size_t count){if(blocked)return false;wire.append(reinterpret_cast(bytes),count);return true;}; + auto read=[&](uint32_t offset,uint8_t* bytes,size_t count){++reads;for(size_t i=0;i[...match[1].matchAll(/"([^"]+)"/g)].map(key=>key[1])); +if(tuiKeys.length<50)throw Error('TUI property catalogue extraction changed'); const start=source.indexOf('String shellGet(const ShellProperty &p){'); const end=source.indexOf(' if(key=="hostname")',start); if(start<0||end #include +#include #include "ShellProperties.h" #define F(text) text using String=std::string; @@ -17,6 +22,8 @@ ${source.slice(start,end)} return "unknown"; } int main(){ +const std::set tuiKeys={${[...new Set(tuiKeys)].map(key=>JSON.stringify(key)).join(',')}}; +for(size_t i=0;i +#include +#include +int main(){ + PakarShell::PixelCommand p; + BoardCore::PixelGroupLayout<80,6,480> table; + assert(PakarShell::pixelCommand("0",table,p)&&p.index==0&&p.count==1); + assert(PakarShell::pixelCommand("479 1 00ff00",table,p)&&p.index==479&&p.rgb==0x00ff00); + assert(PakarShell::pixelCommand("group 80 FF0000",table,p)&&p.index==474&&p.count==6); + for(const char* bad:{"480","-1","1 6","0 2","0 0","0 256","0 1 xyzxyz","group 0","group 81","group","off 1","0 1 FFFFFF extra","0\n"}) + assert(!PakarShell::pixelCommand(bad,table,p)); + uint8_t overrides[80]{};overrides[0]=5;overrides[1]=7;overrides[79]=4; + assert(table.configure(overrides,42)); + assert(PakarShell::pixelCommand("group 2 123456",table,p)&&p.index==5&&p.count==7&&p.revision==42); + assert(PakarShell::pixelCommand("5 7",table,p)&&p.index==5&&p.count==7); + assert(!PakarShell::pixelCommand("6 6",table,p)); + assert(!PakarShell::pixelCommand("478",table,p)); // Capacity is not used prefix. + assert(PakarShell::pixelCommand("off",table,p)&&p.off&&p.revision==42); + BoardCore::PixelGroupLayout<18,12,216> keys; + assert(PakarShell::pixelCommand("group 2 0000ff",keys,p)&&p.index==12&&p.count==12&&p.rgb==255); + assert(PakarShell::pixelCommand("group 18",keys,p)&&p.index==204&&p.count==12); + assert(!PakarShell::pixelCommand("0 6",keys,p)); + assert(!PakarShell::pixelCommand(nullptr,keys,p)); + puts("Shell pixels use the same live topology, group spans and revision as the API/TUI."); +} diff --git a/tests/shell_properties_test.cpp b/tests/shell_properties_test.cpp index 61d5c732..060b0d48 100644 --- a/tests/shell_properties_test.cpp +++ b/tests/shell_properties_test.cpp @@ -1,4 +1,5 @@ #include "../firmware/esp/src/ShellProperties.h" +#include "../firmware/esp/src/ShellLabels.h" #include #include int main(){ @@ -8,18 +9,40 @@ int main(){ #else 0; #endif - assert(shellPropertyCount==46+added); + constexpr unsigned gameAdded= +#if PAKAR_FIRST_WINS_ENABLED + 11; +#else + 0; +#endif + constexpr unsigned sshAdded= +#if PAKAR_SSH_ENABLED + 2; +#else + 0; +#endif + assert(shellPropertyCount==59+added+gameAdded+sshAdded); unsigned secrets=0,writable=0; for(size_t index=0;index +#include +#include +struct Socket { + std::string input,output;unsigned binds=0,stops=0,flushes=0,replies=0;bool bindOk=true; + bool begin(uint16_t port){assert(port==9876);++binds;return bindOk;} + void stop(){++stops;} + int parsePacket(){return input.size();} + void flush(){input.clear();++flushes;} + int read(uint8_t* bytes,int size){input.copy(reinterpret_cast(bytes),size);input.clear();return size;} + int remoteIP(){return 17;}int remotePort(){return 2468;} + bool beginPacket(int ip,int port){assert(ip==17&&port==2468);output.clear();return true;} + void write(const uint8_t* data,size_t size){output.append(reinterpret_cast(data),size);} + void endPacket(){++replies;} +}; +int main(){ + Socket s;PakarShell::RemoteQuery service;unsigned reads=0; + auto status=[&](char* value,size_t capacity){++reads;snprintf(value,capacity,"hostname=fixture boot_id=boot uptime=1");}; + auto run=[&](uint32_t now,uint32_t epoch=1,bool enabled=true){service.service(s,now,epoch,enabled,status);}; + run(0,0);assert(s.binds==0);s.bindOk=false;run(1);run(100);assert(s.binds==1); + s.bindOk=true;run(5001);assert(s.binds==2&&service.listening()); + s.input="0123456789abcdef status";run(5002);assert(s.replies==1&&reads==1&&s.output.find("0123456789abcdef OK hostname=fixture")==0); + s.input="0123456789abcdef reset";run(5003);assert(s.replies==1&&s.flushes==1); + s.input="fedcba9876543210 reset";run(5252);assert(s.replies==2&&reads==1&&s.output.find("fedcba9876543210 ERR read-only:")==0); + s.input=std::string(65,'x');run(5502);assert(s.replies==2&&s.flushes==2); + s.input="0123456789abcdef status\nreset";run(5502);assert(s.replies==2); + s.input="0123456789abcdeg status";run(5502);assert(s.replies==2); + s.input=std::string("0123456789abcdef status\0reset",28);run(5502);assert(s.replies==2); + run(5503,2);assert(s.binds==3&&s.stops==1);run(5504,2,false);assert(!service.listening()&&s.stops==2); + s.input="0123456789abcdef hello";run(0xffffff00U,2);assert(s.replies==3); + s.input="0123456789abcdef status";run(10,2);assert(s.replies==4); // unsigned rollover + puts("UDP bounded read-only query: lifecycle, bind backoff, correlation, rate limit and rejection pass."); +} diff --git a/tests/shell_service_test.cpp b/tests/shell_service_test.cpp index 6c4ec059..0bdd4b56 100644 --- a/tests/shell_service_test.cpp +++ b/tests/shell_service_test.cpp @@ -118,6 +118,11 @@ WiFiClient pending(const char* input=""){ } void serial(){serialShell=std::make_unique();initializeDeviceShell(*serialShell,false);shellChannel.open(9);} int main(){ + reset();accessConfig.authentication=true; + DeviceShellSession sshAuthenticated;initializeDeviceShell(sshAuthenticated,false,true); + assert(sshAuthenticated.authenticated&&sshAuthenticated.output.find("Password:")==std::string::npos); + DeviceShellSession unauthenticated;initializeDeviceShell(unauthenticated,false); + assert(!unauthenticated.authenticated&&unauthenticated.output.find("Password:")!=std::string::npos); reset();auto first=pending("status\r");serviceDeviceShells(); assert(telnetSessions[0]&&first.connected()); assert(first.state->wire.find(APP_NAME)!=std::string::npos&&inputBytes==7); diff --git a/tests/shell_tone_dispatch_test.mjs b/tests/shell_tone_dispatch_test.mjs new file mode 100644 index 00000000..89839b9b --- /dev/null +++ b/tests/shell_tone_dispatch_test.mjs @@ -0,0 +1,49 @@ +// Compile the ACTUAL shell branch and shared AVR buzzer branch together. +// Doubles capture protocol bytes/receipts; they do not prove audible output. +import {readFileSync,writeFileSync,mkdirSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import {resolve} from 'node:path'; +const source=readFileSync('firmware/esp/src/ShellService.h','utf8'); +const app=readFileSync('firmware/esp/application.ino','utf8'); +function section(text,start,end){const a=text.indexOf(start),b=text.indexOf(end,a+start.length);if(a<0||b +#include +#include +#include +#include +#include "ShellToneCommand.h" +#define F(value) value +using String=std::string; +using Value=std::variant; +using Document=std::map; +namespace PakarCommand {long integerValue(const Document& d,const char* key,long fallback){auto at=d.find(key);return at==d.end()?fallback:std::get(at->second);}} +unsigned attempts=0,rejections=0;bool ack=true;std::vector bytes; +constexpr uint8_t kBuzzer=19; +bool submitAvrCommand(uint8_t opcode,const uint8_t* p,size_t n,String& result){assert(opcode==kBuzzer);++attempts;bytes.assign(p,p+n);result=ack?"accepted":"AVR busy";return ack;} +bool handleCloudCommand(const Document& commandRequest,String& result){const auto command=std::get(commandRequest.at("command"));${avr}return false;} +struct Session{} session; +void shellResult(Session&,bool accepted,const String&){if(!accepted)++rejections;} +void shellPrompt(Session&){} +void execute(const String& args){auto& s=session;const String verb="beep";bool valid=true;String result;Document d; +if(false){}${shell} +if(valid)shellResult(s,handleCloudCommand(d,result),result); +} +int main(){ +execute("");assert(attempts==1&&rejections==0&&(bytes==std::vector{232,3,100,0})); +execute("440 250");assert(attempts==2&&(bytes==std::vector{184,1,250,0})); +ack=false;execute("440 250");assert(attempts==3&&rejections==1); +for(const char* bad:{"0","12001 250","440 0","440 5001","nope","440 250 extra"})execute(bad); +assert(attempts==3&&rejections==7); +} +`; +const directory=resolve('build/native');mkdirSync(directory,{recursive:true}); +const generated=resolve(directory,'shell_tone_dispatch_fixture.cpp'),output=resolve(directory,'shell_tone_dispatch_test.exe'); +let prior;try{prior=readFileSync(generated,'utf8')}catch(error){if(error.code!=='ENOENT')throw error;} +if(prior!==fixture)writeFileSync(generated,fixture); +for(const [command,args] of [[process.env.CXX||'g++',['-std=c++17','-O2','-g0','-Wall','-Wextra','-Werror','-I',resolve('firmware/esp/src'),generated,'-o',output]],[output,[]]]){ + const result=spawnSync(command,args,{stdio:'inherit',timeout:60000});if(result.error||result.status!==0)throw Error('Actual shell/AVR tone dispatch fixture failed'); +} +console.log('Actual beep-to-buzzer-to-AVR branch: finite exact tone bytes, rejection, no replay and argument bounds passed; not audible acceptance.'); diff --git a/tests/shell_tui_test.cpp b/tests/shell_tui_test.cpp index 45a94d58..64704059 100644 --- a/tests/shell_tui_test.cpp +++ b/tests/shell_tui_test.cpp @@ -6,6 +6,11 @@ int main(){ using T=PakarShell::ShellTui; + {T pages;T::Snapshot s;T::Action a;s.cloudSettings=s.notificationSettings=true;pages.begin(); + pages.feed('\t',s,a);assert(pages.page()==T::Network); + pages.feed('\t',s,a);assert(pages.page()==T::Cloud); + pages.feed('\t',s,a);assert(pages.page()==T::Notifications); + pages.feed('\t',s,a);assert(pages.page()==T::Device);} T model;T::Action action;T::Snapshot snapshot; uint8_t counts[80];uint16_t starts[80]; for(unsigned i=0;i<80;++i){counts[i]=6;starts[i]=i*6;} @@ -18,7 +23,8 @@ int main(){ auto feed=[&](const std::string& bytes){bool emitted=false;for(unsigned char byte:bytes)emitted=model.feed(byte,snapshot,action)||emitted;return emitted;}; model.begin();assert(model.active());assert(model.render(snapshot,write));assert(output.size()<=4096); assert(output.find("never-render-this")==std::string::npos); - assert(output.find("[read]")!=std::string::npos&&output.find("[edit]")!=std::string::npos); + assert(output.find("Firmware")!=std::string::npos&&output.find("Hostname")!=std::string::npos); + assert(output.find("[read]")==std::string::npos&&output.find("[edit]")==std::string::npos); output.clear();model.feedback("Saved\x1b]52;c;private\a"); assert(model.render(snapshot,write)&&output.find("Saved")!=std::string::npos&&output.find("private")==std::string::npos); assert(action.type==T::Action::None); // Feedback is display-only, never a command. @@ -42,7 +48,7 @@ int main(){ snapshot.canLeds=true;snapshot.canPixels=true;snapshot.pixelCount=480; assert(!feed("\t\t"));assert(model.page()==T::Led); assert(action.type==T::Action::None); // Page entry never actuates. - output.clear();assert(model.render(snapshot,write));assert(output.find("not physical geometry")!=std::string::npos); + output.clear();assert(model.render(snapshot,write));assert(output.find("01")!=std::string::npos); assert(!feed("\x1b[C\x1b[B"));assert(model.selectedCell()==12); assert(feed("r"));assert(action.type==T::Action::Cell&&action.index==12&&std::string(action.value)=="red"); snapshot.canLeds=false;assert(!feed("w")); @@ -67,6 +73,20 @@ int main(){ model.begin(false);output.clear();assert(model.render(snapshot,write));assert(output.find('\x1b')==std::string::npos); assert(sizeof(model)<2048); // No heap, IO object, unbounded transcript or SDK dependency. + T game;T::Snapshot keys;keys.canGame=true;keys.gameReady=true;keys.enabledKeys=3;keys.unicode=true;keys.terminalSettings=true; + game.begin();game.feed('\t',keys,action);game.feed('\t',keys,action); + assert(game.page()==T::Game&&action.type==T::Action::None); + output.clear();assert(game.render(keys,write));assert(output.find("\xe2\x95\xad\xe2\x94\x80 ")!=std::string::npos); + assert(output.find("1-9 press box")!=std::string::npos&&output.size()<4096); + assert(game.feed('1',keys,action)&&action.type==T::Action::Press&&action.index==1); + assert(!game.feed('3',keys,action)&&action.type==T::Action::None); + assert(game.feed('r',keys,action)&&action.type==T::Action::Reset); + keys.gameReady=false;assert(!game.feed('1',keys,action));assert(!game.feed('r',keys,action)); + keys.lines=15;keys.gameReady=true;assert(!game.feed('1',keys,action)); + output.clear();game.render(keys,write);assert(output.find("Resize terminal")!=std::string::npos); + keys.lines=24;game.feed('\t',keys,action);assert(game.page()==T::TerminalPage); + keys.unicode=false;output.clear();game.render(keys,write);assert(output.find("\xe2\x95\xad")==std::string::npos); + // Physical groups follow current prefix spans, not a six-pixel assumption or // the logical table permutation. Every explicit action carries its revision. T variable;T::Snapshot geometry; @@ -113,6 +133,44 @@ int main(){ assert(!input("g"));for(unsigned i=0;i<11;++i)assert(input("+")); assert(action.index==215&&action.count==1);assert(!input("+")); output.clear();assert(variable.render(geometry,write));assert(output.find("/ 216 (216 capacity)")!=std::string::npos); + geometry.lines=15;assert(!input("\x1b[D"));assert(variable.selectedPixel()==215); + geometry.lines=24; + + // A resize redraws an open form without erasing its draft or overflowing the + // narrow editor; small-terminal arrows never become output commands. + T resized;T::Snapshot narrow;T::Row editable={"Device label","label","saved",true,false}; + narrow.rows=&editable;narrow.rowCount=1;resized.begin(); + resized.feed('\r',narrow,action);resized.feed('x',narrow,action); + const std::string savedDraft=resized.draft(); + output.clear();resized.render(narrow,write);narrow.columns=40; + output.clear();assert(resized.render(narrow,write));assert(savedDraft==resized.draft()); + assert(output.find("Edit Device label: savedx")!=std::string::npos); + narrow.lines=15;output.clear();assert(resized.render(narrow,write)); + assert(output.find("Resize terminal")!=std::string::npos&&resized.editing()); + narrow.lines=24;resized.render(narrow,write);assert(savedDraft==resized.draft()); + + // A common 80x16 Telnet viewport is interactive; CSI/SS3 arrows are fed + // one byte per callback, not synchronous reads. Page keys never actuate. + T compact;T::Snapshot small;small.columns=80;small.lines=16;small.terminalSettings=true; + T::Row compactRows[]={{"First","hostname","abc",true,false},{"Second","label","def",true,false},{"Third","label","ghi",true,false}}; + small.rows=compactRows;small.rowCount=3;compact.begin(); + auto key=[&](const char* bytes){bool sent=false;for(;*bytes;++bytes)sent=compact.feed(*bytes,small,action)||sent;return sent;}; + assert(!key("\x1b[B\r")&&compact.editing());assert(std::string(compact.draft())=="def"); + assert(!key("\x1b[1~\x1b[3~"));assert(std::string(compact.draft())=="ef"); + assert(!key("\x1bOFx"));assert(std::string(compact.draft())=="efx"); + key("\x1b");assert(!compact.flushEscape(action)&&!compact.editing()); + assert(!key("\x1b[6~\r")&&std::string(compact.draft())=="ghi"); + key("\x1b");compact.flushEscape(action); + assert(!key("\x1b[5~\r")&&std::string(compact.draft())=="abc"); + key("\x1b");compact.flushEscape(action); + assert(!key("\x1b[C")&&compact.page()==T::Network); + assert(!key("\x1b[D")&&compact.page()==T::Device); + assert(!key("\x1b[Z")&&compact.page()==T::TerminalPage); + assert(!key("\t")&&compact.page()==T::Device); + assert(!key("\x1b[15~\r")&&std::string(compact.draft())=="abc"); // F5 isn't Home/Delete. + output.clear();compact.render(small,write);assert(output.find("\x1b[15;")!=std::string::npos); + key("\x1b");compact.flushEscape(action);compact.render(small,write); + output.clear();key("\x1bOB");compact.render(small,write);assert(output.find("\x1b[2J")==std::string::npos); alignas(T) unsigned char modelStorage[sizeof(T)]{}; auto *dying=new(modelStorage) T;dying->begin(); diff --git a/tests/snake_game_test.cpp b/tests/snake_game_test.cpp new file mode 100644 index 00000000..81753223 --- /dev/null +++ b/tests/snake_game_test.cpp @@ -0,0 +1,14 @@ +#include +#include +#include +int main(){ + using S=BoardCore::SnakeGame; + bool seen[81]={};for(unsigned i=0;i<80;++i){const auto id=S::logical(i);assert(id>=1&&id<=80&&!seen[id]);seen[id]=true;} + assert(S::logical(0)==1&&S::logical(1)==11&&S::logical(20)==21); + assert(S::next(19,1)==0&&S::next(0,3)==19&&S::next(7,0)==67&&S::next(67,2)==7); + S s;assert(!s.service(10000));s.start(0);assert(!s.turn(3));assert(s.turn(0));assert(!s.turn(3)); + assert(!s.service(249));assert(s.service(250));assert(s.body[0]==4); + s.pause();assert(!s.service(5000));s.resume(6000);assert(!s.service(6001));assert(s.service(6250));assert(s.body[0]==64); + s.stop();assert(!s.service(10000));s.start(0);s.food=25;assert(s.service(250));assert(s.length==4&&s.score==1); + assert(BoardCore::GameRole::mode==1);assert(!BoardCore::GameRole::accepts(3)); +} diff --git a/tests/snake_host_test.mjs b/tests/snake_host_test.mjs new file mode 100644 index 00000000..68b629a6 --- /dev/null +++ b/tests/snake_host_test.mjs @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Snake,logicalCell,position} from '../mini-apps/snake/engine.mjs'; +import {Presenter} from '../mini-apps/snake/presenter.mjs'; +import {createSnakeServer} from '../mini-apps/snake/server.mjs'; +import {toolRelease} from '../tools/peer-identity.mjs'; +import {WebSocket,WebSocketServer} from '../services/client-management/node_modules/ws/wrapper.mjs'; +import {readFile} from 'node:fs/promises'; +import {cues,GameAudio} from '../mini-apps/snake/sounds.mjs'; + +test('finite identical audio cues; constructing browser audio is silent',()=>{ + const audio=new GameAudio();assert.equal(audio.context,null);audio.play('start'); + for(const cue of Object.values(cues)){assert.ok(cue.frequency>=20&&cue.frequency<=12000);assert.ok(cue.duration_ms>0&&cue.duration_ms<=180);} +}); + +test('board sounds use peer-fenced command path without changing presentation ownership',async()=>{ + const f=fixture();f.status.capabilities.acknowledged_features.push('buzzer'); + const p=new Presenter({origin:'http://ledcontroller.local',request:f.request,environment:{}}); + await p.connect();await p.frame([{id:1,rgb:0x00ff00}]);const lease=p.lease,revision=p.layer.revision; + await p.sound('eat');assert.equal(p.lease,lease);assert.equal(p.layer.revision,revision); + const packet=JSON.parse(f.calls.at(-1).options.form.packet); + assert.equal(packet.command,'buzzer');assert.equal(packet.frequency,cues.eat.frequency); + assert.equal(packet.duration_ms,cues.eat.duration_ms);assert.equal(packet.owner,undefined); + assert.equal((await p.close()).restored,true); +}); + +test('Stop releases only temporary layer; stale page exit cannot stop a newer round',async()=>{ + let releases=0,frames=0,sounds=0; + class FakePresenter { + info(){return {hostname:'test'};} async connect(){} async frame(){frames++;} + async sound(){sounds++;} async close(){releases++;return {restored:true};} + } + const app=await createSnakeServer({PresenterClass:FakePresenter}); + const cmd=async body=>fetch(new URL('/api/snake',app.url),{method:'POST',headers:{'X-Snake-Token':app.token},body:JSON.stringify(body)}).then(async r=>({status:r.status,body:await r.json()})); + try{ + assert.equal(frames,0);assert.equal(sounds,0); + const first=await cmd({action:'start',board:true});assert.equal(first.body.phase,'running'); + const stopped=await cmd({action:'stop',runId:first.body.runId});assert.equal(stopped.body.restoration.restored,true);assert.equal(releases,1); + const next=await cmd({action:'start',board:true,boardSound:false});assert.equal(sounds,1); + assert.equal((await cmd({action:'stop',runId:first.body.runId})).status,400); + assert.equal(app.snapshot().phase,'running');assert.equal(app.snapshot().runId,next.body.runId); + }finally{await app.close();} +}); + +test('80 table addresses are bijective and match staggered columns',()=>{ + const ids=new Set();for(let y=0;y<4;y++)for(let x=0;x<20;x++){const id=logicalCell(x,y);ids.add(id);assert.deepEqual(position(id),{x,y});} + assert.equal(ids.size,80);assert.deepEqual([0,1,2,3].map(y=>logicalCell(0,y)),[1,21,41,61]);assert.equal(logicalCell(1,0),11); +}); +test('opening is inert; all four edges wrap',()=>{ + const s=new Snake();const before=s.snapshot();s.step();assert.deepEqual(s.snapshot(),before); + assert.equal(s.next(logicalCell(19,2),'right'),logicalCell(0,2)); + assert.equal(s.next(logicalCell(0,2),'left'),logicalCell(19,2)); + assert.equal(s.next(logicalCell(7,0),'up'),logicalCell(7,3)); + assert.equal(s.next(logicalCell(7,3),'down'),logicalCell(7,0)); +}); +test('controls, growth, one queued turn, tail departure and collisions',()=>{ + const s=new Snake({random:()=>0});s.command('start');assert.equal(s.turn('left'),false); + assert.equal(s.turn('up'),true);assert.equal(s.turn('left'),false);s.step();assert.equal(s.direction,'up'); + s.command('pause');const t=s.tick;s.step();assert.equal(s.tick,t);s.command('resume');s.step();assert.equal(s.tick,t+1); + s.command('restart');s.food=[s.next(s.body[0],'right')];s.step();assert.equal(s.score,1);assert.equal(s.body.length,4); + s.command('stop');s.setMap({[s.next(logicalCell(4,1),'right')]:'block'});s.command('restart');s.step();assert.equal(s.phase,'over'); +}); +test('map validation and bounded free food search',()=>{ + const s=new Snake();assert.throws(()=>s.setMap({81:'block'}));assert.throws(()=>s.setMap({1:'teleport'}));assert.throws(()=>s.configure({interval:0})); + s.body=Array.from({length:80},(_,i)=>i+1);s.food=[];s.fillFood();assert.equal(s.phase,'won'); +}); +test('portals, switch and mirror mechanics are not executable map code',()=>{ + const s=new Snake({random:()=>0});const next=s.next(s.body[0],'right');s.setMap({[next]:'teleport',80:'teleport'});s.command('start');s.step();assert.equal(s.body[0],s.next(80,'right')); + s.command('stop');s.reset();s.setMap({[next]:'switch',80:'block'});s.command('start');s.step();assert.equal(s.switchOpen,true); + s.command('stop');s.reset();s.setMap({[next]:'detour1'});s.command('start');s.step();assert.equal(s.direction,'down'); +}); +function fixture({reject=false,address='192.0.2.2'}={}){ + const calls=[],board='table-test',boot='a'.repeat(32),identity={board_id:board,boot_id:boot}; + const status={...identity,version:toolRelease.version,http_peer_fencing:true,hostname:'ledcontroller',command_clock_ms:1000, + capabilities:{hostname:'ledcontroller',board_kind:'table',role:'table-selection',acknowledged_features:['network','host-presentation','table']}}; + let revision=0; + const request=async(_url,path,options={})=>{ + calls.push({path,options});let value; + if(path==='/api/status')value=status; + else if(path==='/api/pixels/groups')value={...identity,revision:4,group_count:80}; + else if(path==='/api/presentation')value={...identity,revision,active:false}; + else{const packet=JSON.parse(options.form.packet);if(reject)throw Error('Lost receipt');value={...identity,request_id:packet.request_id,accepted:true,presentation:{revision:++revision,lease_id:9}};} + return {status:200,body:Buffer.from(JSON.stringify(value)),remote:address}; + };return {calls,request,status}; +} +test('temporary presentation uses logical cells, revision, release and no saved writes',async()=>{ + const f=fixture(),p=new Presenter({origin:'http://ledcontroller.local',request:f.request,environment:{}});await p.connect(); + assert.equal(f.calls.filter(c=>c.options.form).length,0);await p.frame([{id:80,rgb:0x00ff00}]);await p.renew();await p.close(); + const packets=f.calls.filter(c=>c.options.form).map(c=>JSON.parse(c.options.form.packet)); + assert.equal(packets.length,3);assert.equal(packets[0].space,'cells');assert.equal(packets[0].lifetime,'temporary');assert.equal(packets[1].lease_id,9);assert.equal(packets[1].replace,false);assert.equal(packets[2].action,'clear'); +}); + +test('admission uses real firmware role and capabilities, never inferred table size',async()=>{ + for(const change of [s=>s.capabilities.role='first-wins',s=>s.capabilities.board_kind='keys', + s=>s.capabilities.acknowledged_features=['network','table']]){ + const f=fixture();change(f.status);const p=new Presenter({origin:'http://ledcontroller.local',request:f.request,environment:{}}); + await assert.rejects(p.connect(),/acknowledged table-selection/);assert.equal(f.calls.filter(c=>c.options.form).length,0); + } +}); + +test('actual loopback WebSocket carries correlated frames; no HTTP mutations',async()=>{ + const f=fixture({address:'127.0.0.1'}),server=new WebSocketServer({port:0,host:'127.0.0.1'}); + await new Promise(resolve=>server.once('listening',resolve));let revision=0;const packets=[]; + server.on('connection',ws=>ws.on('message',raw=>{ + const v=JSON.parse(raw); + if(v.type==='status')ws.send(JSON.stringify(f.status)); + else if(v.type==='command'){packets.push(v);ws.send(JSON.stringify({type:'command-result',board_id:f.status.board_id,boot_id:f.status.boot_id, + request_id:v.request_id,accepted:true,presentation:{revision:++revision,lease_id:7}}));} + })); + const p=new Presenter({origin:`http://ledcontroller.local:${server.address().port}`,request:f.request,Socket:WebSocket,environment:{}}); + try{await p.connect();assert.equal(p.info().transport,'WebSocket');await p.frame([{id:1,rgb:0xff0000}]);await p.renew();await p.close(); + assert.equal(packets.length,3);assert.equal(packets[2].action,'clear');assert.equal(f.calls.filter(c=>c.options.form).length,0); + }finally{await p.close();for(const ws of server.clients)ws.terminate();await new Promise(resolve=>server.close(resolve));} +}); + +test('runtime disabled rejects starting; invalid map leaves options untouched',async()=>{ + const app=await createSnakeServer({enabled:false}); + try{const r=await fetch(new URL('/api/snake',app.url),{method:'POST',headers:{'X-Snake-Token':app.token},body:'{"action":"start"}'}); + assert.equal(r.status,400);assert.equal(app.snapshot().phase,'ready'); + }finally{await app.close();} + const other=await createSnakeServer(); + try{const before=other.snapshot().options; + const r=await fetch(new URL('/api/snake',other.url),{method:'POST',headers:{'X-Snake-Token':other.token},body:JSON.stringify({action:'configure',options:{interval:80},map:{81:'block'}})}); + assert.equal(r.status,400);assert.deepEqual(other.snapshot().options,before); + }finally{await other.close();} +}); + +test('hidden native and Android launchers only open external UI; board build defaults off',async()=>{ + const read=p=>readFile(new URL('../'+p,import.meta.url),'utf8'); + assert.match(await read('desktop/src/ui/shell.rs'),/Command::Snake => ctx.open_url/); + assert.match(await read('mobile/android/app/src/main/AndroidManifest.xml'),/android:path="\/snake"/); + const activity=await read('mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/SnakeLauncherActivity.kt'); + assert.match(activity,/http:\/\/127.0.0.1:18490\/snake/);assert.doesNotMatch(activity,/api\/command|action.*start/); + const role=await read('libraries/BoardCore/src/GameRole.h');assert.match(role,/#define PAKAR_BOARD_SNAKE 0/); + assert.match(role,/#if PAKAR_BOARD_SNAKE && !PAKAR_TABLE_SELECTION/); +}); +test('unknown receipt blocks replay and cleanup mutations',async()=>{ + const f=fixture({reject:true}),p=new Presenter({origin:'http://ledcontroller.local',request:f.request,environment:{}});await p.connect(); + await assert.rejects(p.frame([{id:1,rgb:0}]),/Lost receipt/);await assert.rejects(p.frame([]),/unknown/);await p.close(); + assert.equal(f.calls.filter(c=>c.options.form).length,1); +}); +test('real loopback launcher serves inert UI, gates POST and controls host game',async()=>{ + let connected=0;class NeverConnect{constructor(){connected++;throw Error('Unexpected board contact');}} + const app=await createSnakeServer({PresenterClass:NeverConnect}); + try{ + const root=new URL(app.url).origin; + const page=await fetch(app.url).then(r=>r.text()); + assert.match(page,/

Snake<\/h1>/);assert.doesNotMatch(page,/DRS-jSnake|David Refoua/); + assert.match(page,/aria-label="Steering"/);assert.equal((page.match(/data-direction=/g)||[]).length,4); + assert.equal(app.snapshot().phase,'ready');assert.equal(connected,0); + assert.equal((await fetch(root+'/api/snake',{method:'POST',body:'{}'})).status,403); + const cmd=async body=>fetch(root+'/api/snake',{method:'POST',headers:{'X-Snake-Token':app.token},body:JSON.stringify(body)}).then(r=>r.json()); + assert.equal((await cmd({action:'start',board:false})).phase,'running');assert.equal((await cmd({action:'pause'})).phase,'paused'); + assert.equal((await cmd({action:'resume'})).phase,'running');assert.equal((await cmd({action:'stop'})).phase,'stopped');assert.equal(connected,0); + }finally{await app.close();} +}); diff --git a/tests/ssh_policy_test.cpp b/tests/ssh_policy_test.cpp new file mode 100644 index 00000000..b2521db2 --- /dev/null +++ b/tests/ssh_policy_test.cpp @@ -0,0 +1,17 @@ +#include "../firmware/esp32s3/src/SshPolicy.h" +#include +#include +#include +int main(){ + using namespace PakarS3::SshPolicy; + assert(portAllowed(22,23)&&portAllowed(2222,23)); + for(auto port:{0,23,80,443,7777})assert(!portAllowed(port,23)); + assert(admitted(7,7,2,2)); + assert(!admitted(0,0,2,2)&&!admitted(7,8,2,2)&&!admitted(7,7,2,3)&&!admitted(7,7,0,0)); + assert(expired(100,50,50)&&!expired(99,50,50)); + assert(expired(5,UINT32_MAX-5,11)&&!expired(4,UINT32_MAX-5,11)); + assert(columns(0)==40&&columns(9999)==240&&columns(120)==120); + assert(rows(-2)==16&&rows(200)==100&&rows(30)==30); + static_assert(packetBytes==128&&handshakeMs +#include +#define PAKAR_SCP_ENABLED 1 +namespace FirmwarePlatform { +struct Sha256 {uint32_t value=0;void begin(){value=0;}void add(const void* bytes,size_t count){const auto*p=static_cast(bytes);for(size_t i=0;i(bytes),count);return true;} + void close(uint32_t){closed=true;} + void finish(uint32_t,unsigned status=0){finished=true;exit=status;} +} deviceSsh; +#include "../firmware/esp/src/SshScpBinding.h" +int main(){ + fs::FS fs;userFiles.begin(fs,true); + FirmwarePlatform::Sha256 digest;const uint8_t bytes[]={0,1,255,13,10};digest.add(bytes,sizeof(bytes)); + JsonDocument body;body["action"]="begin";body["name"]="example.bin";body["size"]=5;body["sha256"]=digest.hex();body["modified_ms"]=123ULL;body["revision"]=0; + JsonDocument response;assert(!deserializeJson(response,userFileRequest(body.as()))&&response["accepted"]==true); + const uint32_t token=response["token"];body.clear();body["action"]="write";body["token"]=token;body["offset"]=0;body["hex"]="0001ff0d0a"; + assert(!deserializeJson(response,userFileRequest(body.as()))&&response["accepted"]==true); + body["action"]="commit";assert(!deserializeJson(response,userFileRequest(body.as()))&&response["committed"]==true); + body["action"]="cancel";userFileRequest(body.as()); + const unsigned writes=fs.writes; + sshScp.token=9;sshScp.revision=7;snprintf(sshScp.name,sizeof(sshScp.name),"example.bin"); + serviceSshScp();assert(sshScp.ready&&deviceSsh.wire.empty()); + sshScp.download.input(0);serviceSshScp();assert(deviceSsh.wire=="C0644 5 example.bin\n"); + sshScp.download.input(0);deviceSsh.blocked=true;serviceSshScp();assert(sshScp.download.sent()==0); + deviceSsh.blocked=false;serviceSshScp();serviceSshScp();sshScp.download.input(0);serviceSshScp(); + const std::string expected=std::string("C0644 5 example.bin\n")+std::string(reinterpret_cast(bytes),5)+std::string(1,0); + assert(deviceSsh.wire==expected&&deviceSsh.finished&&deviceSsh.exit==0&&!deviceSsh.closed&&fs.writes==writes); + sshScp=SshScpSession{};sshScp.token=9;sshScp.revision=7;snprintf(sshScp.name,sizeof(sshScp.name),"example.bin");serviceSshScp();assert(sshScp.ready); + sshScp.hash=String(64,'0');sshScp.download.input(0);serviceSshScp();sshScp.download.input(0);serviceSshScp();assert(deviceSsh.closed); // Changed file cannot become a successful download. + sshScp=SshScpSession{};sshScp.token=9;sshScp.revision=7;snprintf(sshScp.name,sizeof(sshScp.name),".settings");serviceSshScp();assert(sshScp.failed); + serviceSshScp();assert(deviceSsh.exit==1&&fs.writes==writes); + sshScp.token=9;sshScp.revision=7;accessConfig.revision=8;const auto before=deviceSsh.wire;serviceSshScp();assert(!sshScp.token&&deviceSsh.wire==before); + puts("Actual SCP binding + public file store: binary bytes, unchanged-file fencing, private isolation, auth revocation, backpressure and zero-write downloads passed (transport/FS doubles)."); +} diff --git a/tests/ssh_settings_test.cpp b/tests/ssh_settings_test.cpp new file mode 100644 index 00000000..31aa5ca7 --- /dev/null +++ b/tests/ssh_settings_test.cpp @@ -0,0 +1,36 @@ +#include "../firmware/esp32s3/src/SshTransport.h" +#include +#include +#include +#include +#include +// Real SshTransport::begin/configure with an in-memory NVS boundary only. +static std::map> storage; +static bool openAllowed=true,writeAllowed=true,readAllowed=true; +static unsigned writes=0; +bool Preferences::begin(const char* name,bool){assert(std::string(name)=="pakar-ssh");return openAllowed;} +void Preferences::end(){} +bool Preferences::isKey(const char* key){return storage.count(key);} +size_t Preferences::getBytesLength(const char* key){return storage[key].size();} +size_t Preferences::getBytes(const char* key,void* out,size_t count){if(!readAllowed||storage[key].size()!=count)return 0;memcpy(out,storage[key].data(),count);return count;} +size_t Preferences::putBytes(const char* key,const void* input,size_t count){if(!writeAllowed)return 0;++writes;const auto* bytes=static_cast(input);storage[key]={bytes,bytes+count};return count;} +int main(){ + using PakarS3::SshTransport; + SshTransport fresh;assert(fresh.begin()&&!fresh.enabled()&&fresh.port()==22&&writes==0); + for(auto port:{0,23,80,443,7777})assert(!fresh.configure(true,port,23)); + assert(fresh.configure(true,2222,23)&&fresh.enabled()&&fresh.port()==2222&&writes==1); + assert(fresh.configure(true,2222,23)&&writes==1); // No identical writes. + SshTransport reboot;assert(reboot.begin()&&reboot.enabled()&&reboot.port()==2222); + writeAllowed=false;assert(!reboot.configure(false,2222,23)&&reboot.enabled());writeAllowed=true; + assert(reboot.configure(false,2222,23)&&!reboot.enabled()&&writes==2); + SshTransport disabled;assert(disabled.begin()&&!disabled.enabled()); + storage["settings"][0]^=1; + SshTransport corrupt;assert(!corrupt.begin()&&!corrupt.enabled());assert(*corrupt.error()); + assert(!corrupt.configure(true,22,23)); // Never erase corruption to repair it. + storage.clear();openAllowed=false; + SshTransport unavailable;assert(!unavailable.begin()&&!unavailable.enabled()); + openAllowed=true; + SshTransport readback;assert(readback.begin());readAllowed=false; + assert(!readback.configure(true,22,23)&&!readback.enabled()); + std::puts("Actual SSH NVS settings: disabled default, reserved ports, persisted enable/port, identical-write suppression and failure/corruption gates passed; not flash/NVS hardware acceptance."); +} diff --git a/tests/ssh_stubs/Arduino.h b/tests/ssh_stubs/Arduino.h new file mode 100644 index 00000000..7b3af7e5 --- /dev/null +++ b/tests/ssh_stubs/Arduino.h @@ -0,0 +1,14 @@ +#pragma once +// Compile-only transport boundary. This is not a FreeRTOS/hardware simulation. +#include +#include +#include +#include +#include +uint32_t millis(); +struct SshCompileEsp {uint32_t getFreeHeap();}; +extern SshCompileEsp ESP; +using portMUX_TYPE=int; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define portENTER_CRITICAL(lock) ((void)(lock)) +#define portEXIT_CRITICAL(lock) ((void)(lock)) diff --git a/tests/ssh_stubs/Preferences.h b/tests/ssh_stubs/Preferences.h new file mode 100644 index 00000000..6d7953f1 --- /dev/null +++ b/tests/ssh_stubs/Preferences.h @@ -0,0 +1,9 @@ +#pragma once +#include +class Preferences { + public: + bool begin(const char*,bool);void end();bool isKey(const char*); + size_t getBytesLength(const char*);size_t getBytes(const char*,void*,size_t); + size_t putBytes(const char*,const void*,size_t); + size_t getString(const char*,char*,size_t);size_t putString(const char*,const char*); +}; diff --git a/tests/ssh_stubs/fcntl.h b/tests/ssh_stubs/fcntl.h new file mode 100644 index 00000000..1d087fa0 --- /dev/null +++ b/tests/ssh_stubs/fcntl.h @@ -0,0 +1,6 @@ +#pragma once +// Embedded socket API declaration for host syntax checks only. +#define F_GETFL 3 +#define F_SETFL 4 +#define O_NONBLOCK 0x4000 +int fcntl(int,int,...); diff --git a/tests/ssh_stubs/freertos/FreeRTOS.h b/tests/ssh_stubs/freertos/FreeRTOS.h new file mode 100644 index 00000000..d6a474ba --- /dev/null +++ b/tests/ssh_stubs/freertos/FreeRTOS.h @@ -0,0 +1,7 @@ +#pragma once +#include +using BaseType_t=int; +using QueueHandle_t=void*; +#define pdTRUE 1 +#define pdPASS 1 +#define pdMS_TO_TICKS(value) (value) diff --git a/tests/ssh_stubs/freertos/queue.h b/tests/ssh_stubs/freertos/queue.h new file mode 100644 index 00000000..f920b599 --- /dev/null +++ b/tests/ssh_stubs/freertos/queue.h @@ -0,0 +1,8 @@ +#pragma once +#include "FreeRTOS.h" +QueueHandle_t xQueueCreate(unsigned,unsigned); +BaseType_t xQueueReset(QueueHandle_t); +BaseType_t xQueueReceive(QueueHandle_t,void*,unsigned); +BaseType_t xQueueSend(QueueHandle_t,const void*,unsigned); +unsigned uxQueueSpacesAvailable(QueueHandle_t); +unsigned uxQueueMessagesWaiting(QueueHandle_t); diff --git a/tests/ssh_stubs/freertos/task.h b/tests/ssh_stubs/freertos/task.h new file mode 100644 index 00000000..f18a1a97 --- /dev/null +++ b/tests/ssh_stubs/freertos/task.h @@ -0,0 +1,5 @@ +#pragma once +#include "FreeRTOS.h" +BaseType_t xTaskCreatePinnedToCore(void(*)(void*),const char*,unsigned,void*,unsigned,void*,int); +void vTaskDelay(unsigned); +void vTaskDelete(void*); diff --git a/tests/ssh_stubs/sys/select.h b/tests/ssh_stubs/sys/select.h new file mode 100644 index 00000000..f395d5ae --- /dev/null +++ b/tests/ssh_stubs/sys/select.h @@ -0,0 +1,6 @@ +#pragma once +#if defined(_WIN32) +#include +#else +#include_next +#endif diff --git a/tests/ssh_transport_api_test.cpp b/tests/ssh_transport_api_test.cpp new file mode 100644 index 00000000..308e7d6a --- /dev/null +++ b/tests/ssh_transport_api_test.cpp @@ -0,0 +1,4 @@ +#include "../firmware/esp32s3/src/SshTransport.h" +// Parse every actual production method against the pinned real libssh API. +// Stubs cover Arduino/FreeRTOS declarations ONLY; no crypto/wire claim. +static_assert(sizeof(PakarS3::SshTransport::Packet)<160); diff --git a/tests/storage_hygiene_test.ps1 b/tests/storage_hygiene_test.ps1 index 54e74523..4ebe17fc 100644 --- a/tests/storage_hygiene_test.ps1 +++ b/tests/storage_hygiene_test.ps1 @@ -11,6 +11,10 @@ Assert-Hygiene ($taskSummary.Exists -and $taskSummary.Files -eq $taskFiles.Count Assert-Hygiene ($taskSummary.GiB -eq [math]::Round(($taskFiles | Measure-Object Length -Sum).Sum / 1GB,3)) 'Logical byte sum' $taskMissing = Get-DirectorySummary (Join-Path $taskRoot 'tests/nonexistent-storage-fixture') Assert-Hygiene (-not $taskMissing.Exists -and $taskMissing.Files -eq 0 -and $taskMissing.GiB -eq 0) 'Missing directory stays honest' +$taskEmptyPath = Join-Path $taskRoot 'build/tests/storage-hygiene/empty' +New-Item -ItemType Directory -Path $taskEmptyPath -Force | Out-Null +$taskEmpty = Get-DirectorySummary $taskEmptyPath +Assert-Hygiene ($taskEmpty.Exists -and $taskEmpty.Files -eq 0 -and $taskEmpty.GiB -eq 0) 'Empty directory works under strict mode in Windows PowerShell and pwsh' $taskPalette = Join-Path $taskAssets 'palettes.json' Assert-Hygiene (Test-PakarFileContentEqual $taskPalette $taskPalette) 'Actual imported equal-content helper' Assert-Hygiene (-not (Test-PakarFileContentEqual $taskPalette (Join-Path $taskAssets 'appearance.js'))) 'Different file rejection' diff --git a/tests/telnet_client_test.mjs b/tests/telnet_client_test.mjs new file mode 100644 index 00000000..389130df --- /dev/null +++ b/tests/telnet_client_test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import net from 'node:net'; +import {PassThrough} from 'node:stream'; +import {connectShell,telnetDecoder,escapeTelnet} from '../tools/telnet-shell.mjs'; +let replies=[],received=[]; +const decode=telnetDecoder(b=>replies.push([...b]),b=>received.push(...b)); +for(const byte of [255,251,1,255,251,1,255,253,3,255,253,42,255,250,24,65,255,255,66,255,240,120,255,255])decode([byte]); +assert.deepEqual(replies,[[255,253,1],[255,251,3],[255,252,42]]); +assert.deepEqual(received,[120,255]);assert.deepEqual([...escapeTelnet([120,255])],[120,255,255]); +const negotiated=[];let dimensions=[120,40]; +const terminal=telnetDecoder(b=>negotiated.push([...b]),()=>assert.fail('Negotiation leaked to text'),{type:'xterm-256color',size:()=>dimensions}); +for(const byte of [255,253,31,255,253,31,255,253,24,255,250,24,1,255,240])terminal([byte]); +assert.deepEqual(negotiated.slice(0,3),[[255,251,31],[255,250,31,0,120,0,40,255,240],[255,251,24]]); +assert.equal(Buffer.from(negotiated[3].slice(4,-2)).toString(),'xterm-256color'); +dimensions=[255,24];terminal.resize();assert.deepEqual(negotiated.at(-1),[255,250,31,0,255,255,0,24,255,240]); +terminal([255,254,31]);const stopped=negotiated.length;terminal.resize();assert.equal(negotiated.length,stopped); +assert.throws(()=>connectShell({host:'board',execute:'reset\nreset'}),/one printable/); +assert.throws(()=>connectShell({host:'board',port:0,execute:'status'}),/port/); +async function session(kind){ + let commands=0; + const server=net.createServer(socket=>{ + socket.on('error',()=>{}); + socket.write(Buffer.from([255,251,1])); + // Deliberately fragmented negotiation/prompt, as on real TCP streams. + socket.write('PakarController @ fixture\r\n\x1b[36m'); + setImmediate(()=>socket.write(kind==='auth'?'Password: ':'fixture\x1b[0m> ')); + let pending='';const input=telnetDecoder(()=>{},bytes=>{ + pending+=bytes.toString(); + if(pending.includes('\r')){ + ++commands;assert.equal(pending,'status\r');pending=''; + if(kind==='timeout')return; + socket.write(kind==='reject'?'ERR Busy\r\n':'Status fixture\r\n'); + setImmediate(()=>socket.write('fixture> ')); + } + });socket.on('data',input); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const output=new PassThrough();let text='';output.on('data',b=>text+=b); + try{ + const call=connectShell({host:'127.0.0.1',port:server.address().port,execute:'status',deadlineMs:1000,output,input:new PassThrough()}); + if(kind==='auth')await assert.rejects(call,/not sent/); + else if(kind==='reject')await assert.rejects(call,/rejected/); + else if(kind==='timeout')await assert.rejects(call,/not replayed/); + else assert.deepEqual(await call,{completed:true,transport:'tcp',replayed:false}); + assert.equal(commands,kind==='auth'?0:1); + assert.equal(output.listenerCount('drain'),0); + if(kind==='ok')assert.match(text,/Status fixture/); + }finally{await new Promise(resolve=>server.close(resolve));} +} +for(const kind of ['ok','auth','reject','timeout'])await session(kind); +// Exercise the actual interactive stream owner: password bytes go to the peer, +// not stdout; disconnect restores raw mode and removes input listeners. +const ttyIn=new PassThrough(),ttyOut=new PassThrough(),rawModes=[]; +ttyIn.isTTY=ttyOut.isTTY=true;ttyIn.isRaw=false; +ttyIn.setRawMode=value=>{rawModes.push(value);ttyIn.isRaw=value;}; +let transcript='',answered=false,secret=''; +const ttyServer=net.createServer(socket=>{ + socket.on('error',()=>{});socket.write('Password: '); + socket.on('data',bytes=>{secret+=bytes.toString();if(secret.endsWith('\r'))socket.end('Authenticated\r\n');}); +}); +await new Promise(resolve=>ttyServer.listen(0,'127.0.0.1',resolve)); +ttyOut.on('data',bytes=>{transcript+=bytes.toString();if(!answered&&transcript.includes('Password: ')){answered=true;ttyIn.write('test-only-secret\r');}}); +try{ + assert.deepEqual(await connectShell({host:'127.0.0.1',port:ttyServer.address().port,input:ttyIn,output:ttyOut}),{completed:false,transport:'tcp',replayed:false}); + assert.equal(secret,'test-only-secret\r');assert.ok(!transcript.includes('test-only-secret')); + assert.deepEqual(rawModes,[true,false]);assert.equal(ttyIn.listenerCount('data'),0); + assert.ok(ttyIn.isPaused()); // Do not keep a completed CLI alive on stdin. + assert.ok(transcript.endsWith('\x1b[0m\x1b[?1049l')); +}finally{await new Promise(resolve=>ttyServer.close(resolve));} +console.log('TCP/stdin shell: fragmented Telnet, exactly-once command, auth refusal, rejection and deadline loopback passed.'); diff --git a/tests/telnet_shell_test.cpp b/tests/telnet_shell_test.cpp index a0383ab9..2dafcd78 100644 --- a/tests/telnet_shell_test.cpp +++ b/tests/telnet_shell_test.cpp @@ -12,12 +12,29 @@ int main() { auto receive = [&](uint8_t byte) { input.push_back(byte); }; TelnetParser parser; parser.greet(write); - assert((output == std::vector{255,251,1,255,251,3,255,253,3})); + assert((output == std::vector{255,251,1,255,251,3,255,253,3,255,253,31,255,253,24,255,251,0,255,253,0})); output.clear(); - for (uint8_t byte : {255,253,1,255,253,3,255,251,3}) parser.feed(byte, write, receive); + for (uint8_t byte : {255,253,1,255,253,3,255,251,3,255,253,0,255,251,0}) parser.feed(byte, write, receive); assert(output.empty()); // No negotiation loops on greeting acknowledgements. for (uint8_t byte : {255,253,42,255,251,24}) parser.feed(byte, write, receive); - assert((output == std::vector{255,252,42,255,254,24})); + assert((output == std::vector{255,252,42,255,250,24,1,255,240})); + output.clear(); + for(uint8_t byte:{255,251,24,255,251,31,255,251,31})parser.feed(byte,write,receive); + assert(output.empty()); + for(uint8_t byte:{255,250,31,0,120,0,40,255,240})parser.feed(byte,write,receive); + assert(parser.columns()==120&&parser.rows()==40); + for(uint8_t byte:{255,250,24,0,100,117,109,98,255,240})parser.feed(byte,write,receive); + assert(!parser.ansi()&&std::string(parser.terminal())=="dumb"); + // Escaped IAC is data inside NAWS; zero dimensions keep their prior value. + for(uint8_t byte:{255,250,31,0,255,255,0,0,255,240})parser.feed(byte,write,receive); + assert(parser.columns()==240&&parser.rows()==40); + for(uint8_t byte:{255,250,24,0,27,91,50,74,255,240})parser.feed(byte,write,receive); + assert(std::string(parser.terminal())=="dumb"); + for(uint8_t byte:{255,250,24,0})parser.feed(byte,write,receive); + for(unsigned i=0;i<200;++i)parser.feed('x',write,receive); + parser.feed(255,write,receive);parser.feed(240,write,receive); + assert(std::string(parser.terminal())=="dumb"); + assert(!parser.size(1,1)&&parser.size(80,24)); for (uint8_t byte : {255,250,24,65,255,255,66,255,240,115,255,255,255,241,116}) parser.feed(byte, write, receive); assert((input == std::vector{115,255,116})); parser.feed(255, write, receive); parser.reset(); parser.feed('X', write, receive); diff --git a/tests/tone_player_test.cpp b/tests/tone_player_test.cpp index 44bf97f1..ebdaa508 100644 --- a/tests/tone_player_test.cpp +++ b/tests/tone_player_test.cpp @@ -1,6 +1,7 @@ #include "TonePlayer.h" #include "BootMelody.h" #include "AudioCues.h" +#include "FlashToneSequence.h" #include #include #include @@ -72,6 +73,36 @@ int main() { player.streamStatus(status);assert(status[5]==4); // Underrun stays silent. chunk[3]=2;assert(player.stream(chunk,sizeof(chunk))==6); player.stop(); + // The complete flash timeline runs without any further network packets. + uint8_t flash[]={6,3,0,0,0,120,0,100,0,4,144,1}; + assert(player.stream(flash,sizeof(flash))==0); + assert(player.isBusy());player.update(0);assert(player.activeFrequencyHz()==1047); + player.streamStatus(status);assert(status[5]==2&&status[4]==0); + assert(player.stream(flash,sizeof(flash))==6); // No restart on duplicate. + assert(!player.enqueue(2000,20)); // Cannot overwrite timeline storage. + player.update(119);assert(player.activeFrequencyHz()==1047); + player.update(120);assert(player.activeFrequencyHz()==0); + player.update(220);assert(player.activeFrequencyHz()==1319); + player.update(450);assert(player.activeFrequencyHz()==1568); + player.update(670);assert(player.activeFrequencyHz()==2093); + player.update(1059);assert(player.activeFrequencyHz()==2093); + player.update(1060);assert(player.activeFrequencyHz()==0&&player.isBusy()); + player.update(1160);assert(!player.isBusy()); + player.streamStatus(status);assert(status[5]==3); + flash[1]=4;flash[5]=0;assert(player.stream(flash,sizeof(flash))==3); + flash[5]=120;assert(player.stream(flash,sizeof(flash))==0); + player.setMuted(true);player.update(50);assert((TCCR1B & (_BV(CS10)|_BV(CS11)|_BV(CS12)))==0); + player.stop();assert(!player.isBusy());player.setMuted(false); + uint8_t finite[]={7,5,0,0,0,11,2,55,0,142,2,55,0,16,3,100,0}; + assert(player.stream(finite,sizeof(finite))==0); + player.update(100);assert(player.activeFrequencyHz()==523); + assert(player.stream(finite,sizeof(finite))==6); // No audible duplicate replay. + finite[1]=6;finite[7]=0;assert(player.stream(finite,sizeof(finite))==3); + player.update(155);assert(player.activeFrequencyHz()==654); // Invalid replacement left timeline intact. + player.update(210);assert(player.activeFrequencyHz()==784); + player.update(310);assert(!player.isBusy()); + finite[7]=55;assert(player.stream(finite,sizeof(finite))==0); + player.stop();assert(!player.isBusy()); using namespace BoardCore::Protocol; const uint16_t durations[kCueCount] = {530,450,410,300,1080,450,400,600,100,5750,300,300,520,590,240}; CueTimeline timelines[kCueCount]; diff --git a/tests/udp_device_query_test.mjs b/tests/udp_device_query_test.mjs new file mode 100644 index 00000000..a87a7666 --- /dev/null +++ b/tests/udp_device_query_test.mjs @@ -0,0 +1,23 @@ +import {createSocket} from 'node:dgram'; +import {spawn} from 'node:child_process'; +import assert from 'node:assert/strict'; +const socket=createSocket('udp4'); +await new Promise((resolve,reject)=>{socket.once('error',reject);socket.bind(9876,'127.0.0.1',resolve);}); +let requests=0; +socket.on('message',(bytes,peer)=>{ + ++requests;const request=bytes.toString();assert.match(request,/^[a-f0-9]{16} status$/); + socket.send('0000000000000000 OK wrong',peer.port,peer.address); + socket.send(request.slice(0,16)+' OK hostname=fixture boot_id=test',peer.port,peer.address); +}); +try{ + const result=await new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,['tools/udp-device-query.mjs','127.0.0.1','status'],{stdio:['ignore','pipe','pipe']}); + let out='',err='';child.stdout.on('data',b=>out+=b);child.stderr.on('data',b=>err+=b); + const timer=setTimeout(()=>{child.kill();reject(Error('UDP client fixture exceeded deadline'));},6000); + child.on('error',error=>{clearTimeout(timer);reject(error);}); + child.on('exit',code=>{clearTimeout(timer);resolve({code,out,err});}); + }); + assert.equal(result.code,0,result.err);assert.equal(requests,1); + assert.equal(result.out.trim(),'OK hostname=fixture boot_id=test'); + console.log('UDP client loopback rejects uncorrelated reply and sends one read-only query.'); +}finally{socket.close();} diff --git a/tests/update_tool_test.mjs b/tests/update_tool_test.mjs index 2be3eedb..e34b76cf 100644 --- a/tests/update_tool_test.mjs +++ b/tests/update_tool_test.mjs @@ -271,7 +271,8 @@ assert.match(loop,/serviceSemanticPanel\(millis\(\)\)/,'Main loop owns presentat assert.match(background,/serviceUpgrade\(\);[\s\S]*serviceSemanticPanel\(millis\(\)\)/,'Cooperative maintenance also samples the same presenter'); assert.doesNotMatch(info,/serviceSemanticPanel\(|sendToAvr\(/,'Async upload callbacks may publish progress, never perform UART presentation'); assert.match(panelService,/updating&&upgradeLifecycle\.known\(\)\?upgradeLifecycle\.percent\(\):-1/,'Unknown stages stay indeterminate'); -assert.match(panelService,/semanticPanel\.persistent\(updating,text\)/,'Measured update state has priority over ordinary notices'); +assert.match(panelService,/semanticPanel\.persistent\(updating\|\|gameCountdown,text\)/,'Measured updates and active game countdowns retain the persistent presenter'); +assert.match(panelService,/if\(!updating&&miniReady\(\)&&miniGame\.view\(\)\.timed&&miniGame\.running\(\)\)gameCountdown=miniPanel\(text,now\)/,'A game countdown must never replace measured update progress'); assert.match(panelService,/urbootRawOwned\|\|programmingLatched\|\|avrUploadOwner\|\|avrCaptured\|\|[\s\S]*semanticPanel\.manualActive\(now\)/,'Raw UART, Prog, uploads and manual owners inhibit automatic leases'); assert.match(panelService,/semanticPanel\.frame\(now,tm1637DetectionKnown\(\)&&tm1637Ack,inhibited,payload\)/,'Fresh observed TM1637 ACK gates admission'); assert.match(panelService,/if\(updating&&avrMaintenancePanel\)payload\[4\]\|=0x04U/,'Menu override requires the actual AVR acknowledgement'); diff --git a/tests/upnp_description_test.cpp b/tests/upnp_description_test.cpp new file mode 100644 index 00000000..89940af6 --- /dev/null +++ b/tests/upnp_description_test.cpp @@ -0,0 +1,38 @@ +#include +#include +#include +#include "UpnpDescription.h" +#include "BoardIconAsset.h" +#define F(text) text +struct Print { + std::string value; + void print(const char* text){value+=text;} + void print(unsigned number){value+=std::to_string(number);} + void write(uint8_t byte){value+=char(byte);} +}; +namespace PakarS3 { +struct SsdpDevice { + const char *m_deviceType="urn:schemas-upnp-org:device:Basic:1",*m_friendlyName="Table & Keys \"name\" 'ok'", + *m_manufacturer="AtomicDeploy",*m_manufacturerURL="https://github.com/atomicdeploy",*m_modelName="Pakar Test", + *m_modelNumber="0.2.8",*m_modelURL="https://github.com/atomicdeploy/PakarController",*m_serialNumber="ABCDEF", + *m_uuid="38323636-4558-4dda-9188-cda0e6abcdef",*m_presentationURL="/"; + uint32_t m_configId=0; + void refreshConfigId();void schema(Print&)const; +}; +/*__PRODUCTION_SCHEMA__*/ +} +int main(){ + using namespace PakarDiscovery; + PakarS3::SsdpDevice device;device.refreshConfigId();const auto identity=device.m_configId; + device.refreshConfigId();if(device.m_configId!=identity||identity>0xffffff||!identity)return 1; + device.m_friendlyName="Changed";device.refreshConfigId();if(device.m_configId==identity)return 2; + device.m_friendlyName="Table & Keys \"name\" 'ok'";device.refreshConfigId(); + const char* a[]={"ab","c"};const char* b[]={"a","bc"};if(configId(a,2)==configId(b,2))return 3; + Print output;device.schema(output);const auto at=output.value.find("\r\n\r\n"); + if(at==std::string::npos)return 4; + const auto xml=output.value.substr(at+4); + if(xml.find("")!=std::string::npos||xml.find("")!=std::string::npos)return 5; + if(xml.find("Table & Keys <One> "name" 'ok'")==std::string::npos)return 6; + std::cout<readFileSync(p,'utf8'); +const source=read('firmware/esp32s3/src/SsdpDevice.cpp'); +const a=source.indexOf('void SsdpDevice::refreshConfigId()'),b=source.indexOf('void SsdpDevice::handleClient()',a); +assert(a>=0&&b>a,'Actual schema fixture markers changed'); +const fixture=read('tests/upnp_description_test.cpp').replace('/*__PRODUCTION_SCHEMA__*/',source.slice(a,b)); +const dir=resolve('build/native');mkdirSync(dir,{recursive:true}); +const cpp=resolve(dir,'upnp_description_fixture.cpp'),exe=resolve(dir,'upnp_description_test.exe'); +let old;try{old=read(cpp)}catch(e){if(e.code!=='ENOENT')throw e;}if(old!==fixture)writeFileSync(cpp,fixture); +for(const [role,flags] of [['table',['-DPAKAR_GAME_MODE=1']],['keys',['-DPAKAR_GAME_MODE=2']],['app-icon',['-DPAKAR_GAME_MODE=1','-DPAKAR_TABLE_ENABLED=0']]]){ + const compile=spawnSync(process.env.CXX||'g++',['-std=c++17','-O2','-g0','-Wall','-Wextra','-Werror',...flags,'-I',resolve('tests/upnp_stubs'),'-I',resolve('libraries/BoardCore/src'),'-I',resolve('firmware/esp/src'),cpp,'-o',exe],{stdio:'inherit',timeout:60000}); + assert.equal(compile.status,0,'UPnP fixture compile'); + const run=spawnSync(exe,[],{encoding:'utf8',timeout:10000});assert.equal(run.status,0,run.stderr); + const [xml,hex]=run.stdout.split('ICON '),png=Buffer.from(hex.trim(),'hex'); + const config=xml.match(//); + assert(config&&+config[1]>0&&+config[1]<=0xffffff,'Description must identify the advertised config'); + assert(xml.includes('11')); + const tags=['deviceType','friendlyName','manufacturer','manufacturerURL','modelName','modelNumber','modelURL','serialNumber','UDN','iconList','presentationURL']; + let previous=-1;for(const tag of tags){const at=xml.indexOf(`<${tag}>`);assert(at>previous,`UPnP description order: ${tag}`);previous=at;} + assert(png.equals(Buffer.from(read(`assets/branding/${role}.png.base64`).trim(),'base64')),'HTTP icon bytes must match peer artwork'); + assert(xml.includes(`${png.readUInt32BE(16)}${png.readUInt32BE(20)}`)); + const channels={0:1,2:3,3:1,4:2,6:4}[png[25]];assert(xml.includes(`${png[24]*channels}`)); + const hash=createHash('sha256').update(png).digest('hex');assert(read('firmware/esp/src/BoardIconAsset.h').includes(hash)); + for(const origin of ['http://192.168.4.1/description.xml','http://192.168.1.201:8080/description.xml','http://10.0.0.20/description.xml']){ + const icon=xml.match(/([^<]+)<\/url>/)[1];assert.equal(new URL(icon,origin).origin,new URL(origin).origin); + } + writeFileSync(resolve(dir,`upnp-description-${role}.xml`),xml); +} +const app=read('firmware/esp/application.ino'),access=read('firmware/esp/src/WebAccess.h'); +assert(app.includes('AsyncURIMatcher::exact("/board-icon.png"), HTTP_GET|HTTP_HEAD')); +assert(app.includes('serveStaticAsset(request,"image/png",kBoardIconPng,sizeof(kBoardIconPng),kBoardIconEtag)')); +assert(access.includes('path == F("/board-icon.png")')); +assert(app.includes('PakarDiscovery::description(output,'),'Retained ESP8266 also shares XML renderer'); +console.log('Actual UPnP schema: role PNG bytes/dimensions/depth, escaping, cache ID, no phantom services and AP/STA/Ethernet-relative URLs passed. Not LAN/router acceptance.'); diff --git a/tests/upnp_stubs/Arduino.h b/tests/upnp_stubs/Arduino.h new file mode 100644 index 00000000..8ec17336 --- /dev/null +++ b/tests/upnp_stubs/Arduino.h @@ -0,0 +1,3 @@ +#pragma once +#include +#define PROGMEM diff --git a/tests/user_file_store_test.cpp b/tests/user_file_store_test.cpp new file mode 100644 index 00000000..560a0eba --- /dev/null +++ b/tests/user_file_store_test.cpp @@ -0,0 +1,38 @@ +#include "UserFileStore.h" +#include +#include +// Deterministic digest double exercises transaction integrity branches. The Web +// SHA-256 is separately compared against node:crypto; firmware uses mbedTLS. +struct Digest {uint32_t value=0;void begin(){value=0;}void add(const void *bytes,size_t n){auto p=static_cast(bytes);for(size_t i=0;i; +int main(){ + fs::FS fs;Store store;store.begin(fs,true);uint32_t time=1,security=7,token=123; + auto call=[&](const char *action,JsonDocument &body){JsonDocument result;assert(!deserializeJson(result,store.request(action,body.as(),time,security,token)));return result;}; + JsonDocument body;auto listed=call("list",body);assert(listed["accepted"]==true&&listed["files"].size()==0&&fs.writes==0); + for(const char *path:{"../access.bin","/access.bin",".hidden","a/b","a\\b","a%2fb","x..y",""})assert(!Store::nameAllowed(path)); + Digest digest;const uint8_t bytes[]={0,1,255,13,10};digest.add(bytes,sizeof(bytes)); + body["name"]="example.bin";body["size"]=5;body["sha256"]=digest.hex();body["modified_ms"]=1720000000123ULL;body["revision"]=0; + assert(call("begin",body)["accepted"]==true); + JsonDocument chunk;chunk["token"]=123;chunk["offset"]=0;chunk["hex"]="0001ff0d0a"; + assert(call("write",chunk)["received"]==5);assert(call("write",chunk)["received"]==5); + chunk["hex"]="0000ff0d0a";assert(call("write",chunk)["error"]=="duplicate_chunk_mismatch"); + auto committed=call("commit",chunk);assert(committed["committed"]==true&&committed["modified_ms"]==1720000000123ULL); + const auto writes=fs.writes;assert(call("commit",chunk)["committed"]==true&&fs.writes==writes); + call("cancel",chunk);Store reboot;reboot.begin(fs,true);JsonDocument empty; + JsonDocument reopened;assert(!deserializeJson(reopened,reboot.request("list",empty.as(),time,security,456))); + assert(reopened["files"][0]["modified_ms"]==1720000000123ULL&&fs.writes==writes); + JsonDocument read;read["name"]="example.bin";read["sha256"]=digest.hex();read["offset"]=0;read["count"]=5; + assert(call("read",read)["hex"]=="0001ff0d0a");read["sha256"]="bad";assert(call("read",read)["error"]=="file_changed"); + body["revision"]=1;assert(call("begin",body)["error"]=="explicit_replace_required");body["replace_sha256"]=digest.hex(); + token=124;chunk["token"]=token;assert(call("begin",body)["accepted"]==true);chunk["hex"]="0001ff0d0a";assert(call("write",chunk)["received"]==5); + fs.failRename=true;assert(call("commit",chunk)["error"]=="file_publish_failed");fs.failRename=false; + assert(call("list",empty)["revision"]==1);assert(call("commit",chunk)["committed"]==true);call("cancel",chunk); + token=125;chunk["token"]=token;body["revision"]=2;assert(call("begin",body)["accepted"]==true);security++;assert(call("write",chunk)["error"]=="transfer_expired_or_not_owned"); + assert(call("begin",body)["accepted"]==true);time+=60001;assert(call("commit",chunk)["error"]=="transfer_expired_or_not_owned"); + // Incomplete data never publishes and unrelated private files remain untouched. + fs.files["/access.bin"]=std::make_shared("private-secret");assert(call("begin",body)["accepted"]==true);assert(call("commit",chunk)["error"]=="file_integrity_mismatch"); + assert(*fs.files["/access.bin"]=="private-secret");read["name"]="/access.bin";assert(call("read",read)["error"]=="file_not_found"); + *fs.files["/.user-index.0.json"]="{broken";auto before=fs.writes;assert(call("list",empty)["revision"]==1&&fs.writes==before); + *fs.files["/.user-index.1.json"]="{broken";assert(call("list",empty)["error"]=="file_index_damaged"&&fs.writes==before); + puts("Managed files: byte/date persistence, explicit replacement, private isolation, no-write reads, chunk replay, corruption, failure and lease fences passed."); +} diff --git a/tests/user_files_test.mjs b/tests/user_files_test.mjs new file mode 100644 index 00000000..d0eb07db --- /dev/null +++ b/tests/user_files_test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {createHash,randomBytes} from 'node:crypto'; +import vm from 'node:vm'; +const source=await readFile(new URL('../assets/web/user-files.js',import.meta.url),'utf8'); +const context=vm.createContext({Uint8Array,Uint32Array,DataView,Blob,TextEncoder});vm.runInContext(source,context); +const api=context.UserFiles; +for(const size of [0,1,5,55,56,63,64,65,511,512,513,131072]){ + const bytes=randomBytes(size),hash=createHash('sha256').update(bytes).digest('hex');assert.equal(api.sha256(bytes),hash);assert.deepEqual(Buffer.from(api.unhex(api.hex(bytes))),bytes); + const entry={name:'binary.dat',size,sha256:hash,modified_ms:1720000000123}; + const archive=Buffer.from(await api.archive(entry,bytes).arrayBuffer()); + assert.equal(archive.subarray(512,512+30).toString().split('\n')[0],'24 mtime=1720000000.123'); + assert.equal(archive[156],120);assert.equal(archive[1024+156],48); + assert.deepEqual(archive.subarray(1536,1536+size),bytes); + for(const offset of [0,1024]){const h=Buffer.from(archive.subarray(offset,offset+512)),saved=parseInt(h.subarray(148,154).toString(),8);h.fill(32,148,156);assert.equal(h.reduce((n,b)=>n+b,0),saved);} +} +for(const name of ['../x','/access.bin','.hidden','a/b','a\\b','a%2fb','a..b',''])assert.equal(api.validName(name),false); +assert(api.validName('My-file_1.bin'));assert.throws(()=>api.unhex('0g')); +const service=await readFile(new URL('../firmware/esp/src/UsbApiService.h',import.meta.url),'utf8'); +assert(service.indexOf('if(!maintenanceApiIdle())'){const n=new Element(tag,text);elements.push(n);return n;}; +const button=(label,run)=>{const n=node('button','',label);n.onclick=run;return n;}; +const select=items=>{const n=node('select');for(const [value,label]of items){const o=node('option','',label);o.value=value;n.append(o);}n.value=items[0][0];return n;}; +let state={board_id:'board-a',boot_id:'boot-a'},saved=[],rev=0,uploads=0,writes=0,token=44,staged,stall=false,release; +const workspace=api.create({body,card,node,button,select,snapshot:()=>state,peer:()=>({known:true,has:cap=>cap==='managed-files'}),pin:()=>({board:state.board_id,boot:state.boot_id}),api:async(path,options)=>{ + assert.equal(path,'/api/maintenance/request');const request=JSON.parse(options.body),b=request.body;assert.equal(request.board_id,state.board_id); + if(stall){await new Promise(resolve=>release=resolve);return {accepted:true,files:[],revision:0,next_offset:0,more:false};} + if(b.action==='list')return {accepted:true,files:saved,revision:rev,next_offset:saved.length,more:false}; + if(b.action==='begin'){uploads++;staged=b;return {accepted:true,token};} + if(b.action==='write'){writes++;return {accepted:true,received:b.offset+b.hex.length/2};} + if(b.action==='commit'){saved=[{name:staged.name,size:staged.size,sha256:staged.sha256,modified_ms:staged.modified_ms}];rev++;return {accepted:true,committed:true,sha256:staged.sha256,modified_ms:staged.modified_ms};} + if(b.action==='cancel')return {accepted:true}; + throw Error('Unexpected '+b.action); +}}); +workspace.update();assert.equal(uploads,0);assert(elements.find(n=>n.tagName==='select').options[1].disabled); +const reload=elements.find(n=>n.textContent==='Reload'),upload=elements.find(n=>n.textContent==='Upload file');assert(upload.disabled); +await reload.onclick();assert(!upload.disabled);const picker=elements.find(n=>n.type==='file'),bytes=randomBytes(1025); +picker.files=[{name:'test.bin',size:bytes.length,lastModified:1720000000123,arrayBuffer:async()=>bytes}];await picker.onchange(); +assert.equal(uploads,1);assert.equal(writes,3);assert.equal(saved[0].sha256,api.sha256(bytes));assert.equal(saved[0].modified_ms,1720000000123); +// A late file index from the previous peer never populates the new board. +stall=true;const pending=reload.onclick();await new Promise(setImmediate);state={board_id:'board-b',boot_id:'boot-b'};workspace.update();release();await pending; +assert(upload.disabled);assert(elements.some(n=>n.textContent?.includes('Device session changed')));workspace.dispose(); +console.log('Web Files workspace: explicit reads/writes, upload chunks, byte/date readback, optional-volume gate and late-peer reply rejection passed.'); diff --git a/tests/web_access_fence_test.cpp b/tests/web_access_fence_test.cpp index 97da111e..0d743066 100644 --- a/tests/web_access_fence_test.cpp +++ b/tests/web_access_fence_test.cpp @@ -24,6 +24,16 @@ int main() { auto dispatch = [&](AsyncWebServerRequest &request) { access.middleware()->run(&request, [&]() { ++admitted; }); }; + // Discovery artwork and its XML are public reads even with auth enabled; + // the exception must never admit POST or an arbitrary settings/file URL. + for(const char* path:{"/description.xml","/board-icon.png","/app-icon.png"}){ + for(int method:{HTTP_GET,HTTP_HEAD}){ + AsyncWebServerRequest icon;icon.path=path;icon.requestMethod=method;icon.basicAllowed=false; + const unsigned before=admitted;dispatch(icon);assert(admitted==before+1&&icon.status==0); + } + AsyncWebServerRequest mutation;mutation.path=path;mutation.requestMethod=HTTP_POST;mutation.basicAllowed=false; + const unsigned before=admitted;dispatch(mutation);assert(admitted==before&&mutation.status==401); + } for(const char *version:{"0.2.6","0.2.7-alpha","","invalid"}) { AsyncWebServerRequest request;request.path="/api/cell";request.requestMethod=HTTP_POST; request.headers["X-Pakar-Version"].text=version;const unsigned before=admitted; diff --git a/tests/web_appearance_fixture.mjs b/tests/web_appearance_fixture.mjs index 5018b29c..74b75627 100644 --- a/tests/web_appearance_fixture.mjs +++ b/tests/web_appearance_fixture.mjs @@ -46,6 +46,7 @@ if(appFixture){ if(interactiveFixture)status.capabilities.acknowledged_features.push('command-ack',...(contextProfile==='table'?['cell-batch','cell-lock']:[])); } status.state_presentation={enabled:false,fade_ms:350,pulse_ms:1200,ring_ms:800,chase_ms:1200,states:{idle:{rgb:16777215,motion:0},won:{rgb:65280,motion:2},lost:{rgb:16711680,motion:1},off:{rgb:0,motion:0}}}; + if(contextProfile==='table')status.state_presentation.fire_rotation=0; if(status.capabilities&&process.argv.includes('--peripherals')){ status.capabilities.acknowledged_features.push('buzzer','display'); Object.assign(status,{avr_online:true,tm1637_ack:true,tm1637_brightness:5,audio_cues:['welcome','winner','attention']}); @@ -72,7 +73,7 @@ const receipts=new Map(); function fixtureCommand(packet){ if(packet?.board_id!==status.board_id||packet?.boot_id!==status.boot_id)throw Error('peer_changed'); if(!/^[a-f0-9]{32}$/.test(packet.request_id||''))throw Error('invalid_request_id'); - const feature={cells:'cell-batch','game-press':'game-press',reset:'reset-ack','cell-lock':'cell-lock','pixel-diagnostic':'pixel-diagnostic',effect:'leds'}[packet.command]; + const feature={cells:'cell-batch','game-press':'game-press',reset:'reset-ack','cell-lock':'cell-lock','pixel-diagnostic':'pixel-diagnostic','state-presentation':'state-presentation',effect:'leds'}[packet.command]; if(!feature||!status.capabilities?.acknowledged_features.includes(feature))throw Error('fixture_command_unavailable'); const prior=receipts.get(packet.request_id);if(prior){if(prior.packet!==JSON.stringify(packet))throw Error('request_id_conflict');return prior.receipt;} const now=Date.now()-started+100;if(!Number.isSafeInteger(packet.expires_at_ms)||packet.expires_at_msnow+10000)throw Error('command_expired'); @@ -93,6 +94,23 @@ function fixtureCommand(packet){ if(!Number.isInteger(packet.index)||!Number.isInteger(packet.count)||packet.index<0||packet.count<1||packet.index+packet.count>status.pixels||!Number.isInteger(packet.rgb)||packet.rgb<0||packet.rgb>0xffffff)throw Error('invalid_pixel'); status.led_output.diagnostic={revision:status.led_output.diagnostic.revision+1,active:true,index:packet.index,count:packet.count,rgb:packet.rgb,off:packet.off===true};break; } + case 'state-presentation': { + const settings=packet.settings; + if(!settings||typeof settings!=='object')throw Error('invalid_settings'); + if('fire_rotation' in settings){ + if(contextProfile!=='table'||![0,90,180,270].includes(settings.fire_rotation))throw Error('invalid_rotation'); + status.state_presentation.fire_rotation=settings.fire_rotation; + } + for(const key of ['fade_ms','pulse_ms','ring_ms','chase_ms'])if(key in settings){if(!Number.isInteger(settings[key])||settings[key]<0||settings[key]>65535)throw Error('invalid_timing');status.state_presentation[key]=settings[key];} + if('enabled' in settings)status.state_presentation.enabled=settings.enabled===true; + for(const [key,value] of Object.entries(settings.states||{})){ + if(!['idle','won','lost','off'].includes(key)||!value||typeof value!=='object')throw Error('invalid_state'); + if('motion' in value&&(!Number.isInteger(value.motion)||value.motion<0||value.motion>3))throw Error('invalid_motion'); + if('rgb' in value&&(!Number.isInteger(value.rgb)||value.rgb<0||value.rgb>0xffffff))throw Error('invalid_rgb'); + Object.assign(status.state_presentation.states[key],value); + } + break; + } case 'effect':if(!['rainbow','none'].includes(packet.value))throw Error('invalid_effect');status.effect=packet.value;break; default:throw Error('fixture_command_unavailable'); } diff --git a/tests/web_appearance_mount_test.mjs b/tests/web_appearance_mount_test.mjs new file mode 100644 index 00000000..294998c4 --- /dev/null +++ b/tests/web_appearance_mount_test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +const html=readFileSync(new URL('../web/index.html',import.meta.url),'utf8'); +// Keep operator-facing success concise; verification details belong in logs, +// protocol fields and acceptance evidence, not repeated control-panel narration. +for(const path of ['web/index.html','mobile/lib/main.dart','desktop/src/ui/shell.rs']) + assert.doesNotMatch(readFileSync(new URL('../'+path,import.meta.url),'utf8'),/physical output not verified/i); +const code=html.slice(html.indexOf('function renderStateAppearance(){'),html.indexOf('function renderAssignmentTools(){')); +// Delayed capability arrival after Studio has flattened its old .stack columns. +// The old renderer dereferenced null.appendChild here on first live identity. +const ids=new Map(); +class Node { + constructor(tag){this.tagName=tag;this.dataset={};this.children=[];this.style={};this.type='';} + set id(v){this._id=v;ids.set(v,this);}get id(){return this._id;} + set innerHTML(value){for(const match of value.matchAll(/<(\w+)[^>]*\bid="([^"]+)"[^>]*>/g)){const n=new Node(match[1]);n.id=match[2];this.appendChild(n);}} + appendChild(n){assert(n instanceof Node);n.parentElement=this;this.children.push(n);return n;} + append(...items){for(const n of items)this.appendChild(n);} + prepend(n){n.parentElement=this;this.children.unshift(n);} + add(n){this.appendChild(n);} + querySelector(s){return ids.get(s.slice(1))??null;} + querySelectorAll(){return [...ids.values()].filter(n=>['input','select','button'].includes(n.tagName));} + setAttribute(){} +} +const layout=new Node('main');let present=true; +const context=vm.createContext({document:{createElement:t=>new Node(t),createTextNode:t=>Object.assign(new Node('text'),{textContent:t}), + getElementById:id=>ids.get(id),querySelector:s=>s==='.layout'&&present?layout:null,activeElement:null}, + currentPeer:()=>({has:()=>true,role:'first-wins'}),state:{state_presentation:{enabled:true,states:{won:{rgb:0x00ff00,motion:2}}}}, + presentationBusy:false,authenticated:true,peerHttp:{writable:()=>true},saveStateAppearance(){},temporaryStateColor(){}, + uiTr:text=>text,setUiText:(node,text)=>{node.textContent=text;}, + bulkState:{querySelector:()=>true},Option:function(text,value){return Object.assign(new Node('option'),{textContent:text,value});}}); +vm.runInContext(code,context); +present=false;vm.runInContext('renderStateAppearance()',context);assert.equal(layout.children.length,0); +present=true;vm.runInContext('renderStateAppearance()',context); +assert.equal(layout.children.length,1);assert.equal(ids.get('stateAppearanceCard').dataset.capability,'state-presentation'); +const motion=ids.get('stateMotion-won');assert.equal(motion.value,'2');context.document.activeElement=motion; +context.state.state_presentation.states.won.motion=1;vm.runInContext('renderStateAppearance()',context); +assert.equal(motion.value,'1','Focused dropdown must reflect confirmed external changes'); +assert.equal(layout.children.length,1,'Reconnect does not duplicate a panel'); +assert(ids.get('fireDirectionField').hidden,'Keycontroller does not expose a matrix rotation'); +context.currentPeer=()=>({has:()=>true,role:'table-selection'}); +context.state.state_presentation.fire_rotation=0; +vm.runInContext('renderStateAppearance()',context); +assert.equal(ids.get('fireDirectionField').hidden,false); +assert.equal(ids.get('fireDirection').value,'0'); +const sent=[];context.saveStateAppearance=patch=>sent.push(patch); +ids.get('fireDirection').value='180';ids.get('fireDirection').onchange(); +assert.equal(sent.length,1);assert.equal(sent[0].fire_rotation,180); +assert.equal(ids.get('fireDirection').value,'0','Keep actual direction until the board acknowledges'); +context.state.state_presentation.fire_rotation=180; +vm.runInContext('renderStateAppearance()',context);assert.equal(ids.get('fireDirection').value,'180'); +assert.equal(sent.length,1,'Readback never replays the effect'); +delete context.state.state_presentation.fire_rotation; +vm.runInContext('renderStateAppearance()',context);assert(ids.get('fireDirectionField').hidden,'Older firmware stays compatible'); +console.log('Delayed appearance mount after Studio, reconnect and focused live dropdown passed'); diff --git a/tests/web_cell_table_test.mjs b/tests/web_cell_table_test.mjs index 6e2c48c7..99f063cd 100644 --- a/tests/web_cell_table_test.mjs +++ b/tests/web_cell_table_test.mjs @@ -29,7 +29,10 @@ assert(!nativeTable.includes('fill = blend(fill, v.selection.bg_fill')); assert(nativeShell.includes('if search.changed() { self.apply_cell_search_selection(); }')); assert(compact(nativeShell).includes('ifself.sync_cell_selection_search(){ctx.request_repaint();}')); const script=source.match(/'), ['const upper=1;']); +assert.deepEqual(scriptBodies('`; +const server=http.createServer((request,response)=>{if(request.url!=='/'){response.writeHead(404).end();return;}response.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}).end(html);}); +const port=Number(process.argv[2]||0);if(!Number.isInteger(port)||port<0||port>65535)throw Error('Invalid loopback port'); +server.listen(port,'127.0.0.1',()=>console.log('Isolated Files fixture: http://127.0.0.1:'+server.address().port+'/')); diff --git a/web/index.html b/web/index.html index 781dba7d..84dcfd6f 100644 --- a/web/index.html +++ b/web/index.html @@ -332,6 +332,7 @@ .studio .correction-rule-map{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:8px 6px;border:0;padding:6px 2px;max-height:none;overflow:visible} .studio .correction-rule-map>.mapping-pixel{width:36px}.studio .correction-rule-map .mapping-dot{inline-size:28px;block-size:28px} +.mini-mode-choice{display:flex;align-items:center;gap:12px;width:100%;min-height:64px;text-align:start}.mini-mode-choice>span,.web-menu-item .menu-copy{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px}.mini-mode-choice small,.web-menu-item small{display:block;color:var(--muted);font-size:12px;font-weight:400;line-height:1.4;white-space:normal}.mini-mode-choice strong{line-height:1.3}.studio .web-menu-item:has(.menu-copy){min-height:60px;max-width:430px}.mini-mode-choice>.icon{flex:none;color:var(--accent)} .application-activity{margin:18px 0 0;border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden} .application-activity>summary{min-height:38px;display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;list-style:none;font-size:12px;font-weight:600;line-height:1.25} .application-activity>summary::-webkit-details-marker{display:none}.application-activity>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} @@ -355,6 +356,12 @@ [data-control-density=compact] #workspace :is(button,select,input:not([type=range]):not([type=checkbox]):not([type=color]):not([type=file])){min-height:30px;padding-block:5px;line-height:1.25} [data-control-density=compact] #workspace .studio-square{width:30px;height:30px;padding:5px} @media(max-width:600px){.application-event{grid-template-columns:16px minmax(0,1fr) auto}.application-event time{grid-column:2;grid-row:2}.application-command-open>span{display:none}.application-command-open{width:36px;min-width:36px;height:36px;padding:8px}} +/* Shared file workspace: compact columns on desktop, wrapping on mobile. */ +.file-browser-list{display:flex;flex-direction:column;gap:8px;max-height:55vh;overflow:auto} +.file-browser-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px;border-bottom:1px solid var(--line)} +.file-browser-name{flex:1 1 140px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600} +.file-browser-row .muted{font-size:.85em;flex:1 1 180px} +#userFilesCard progress{width:100%;accent-color:var(--accent)} /* END STUDIO CSS *//* BEGIN RAYANLAMP TOAST CSS */ /* RayanLamp app.css toast/viewport/motion/fallback slices; shared Pealayer roles. @@ -785,7 +792,7 @@ // Pealayer web_ui/src/i18n.ts::tr semantics: translate application copy only. // Generated shared dictionary follows; peer values never pass through tr. // BEGIN SHARED PERSIAN DICTIONARY -const PAKAR_FA={"Disconnected":"قطع‌شده","Hardware":"سخت‌افزار","Network":"شبکه","Pulse":"تپش","Revision":"بازنگری","Import colors…":"ورود رنگ‌ها…","Import cell colors":"ورود رنگ خانه‌ها","Imported":"واردشده","Board sound":"صدای برد","AVR name":"نام AVR","Mutes the physical board buzzer, including welcome and menu sounds. Saves directly on the board; browser sound is separate.":"بیزر خود برد، از جمله صدای خوشامد و منوها را بی‌صدا می‌کند. مستقیم روی برد ذخیره می‌شود؛ صدای مرورگر جدا است.","Appearance is saved in this browser. Board sound changes save directly to the board.":"ظاهر در این مرورگر ذخیره می‌شود. تغییرات صدای برد مستقیم روی برد ذخیره می‌شوند.","Programming safety keeps the board muted.":"حفاظت هنگام برنامه‌ریزی، برد را بی‌صدا نگه می‌دارد.","Verified · saved in EEPROM":"تأیید شد · ذخیره‌شده در EEPROM","Reading board…":"در حال خواندن برد…","LEDs & cells":"LEDها و خانه‌ها","AVR & inputs":"AVR و ورودی‌ها","Firmware & storage":"میان‌افزار و ذخیره‌سازی","Search settings…":"جست‌وجوی تنظیمات…","Close device settings":"بستن تنظیمات دستگاه","Device settings pages":"صفحه‌های تنظیمات دستگاه","Discard & reload…":"لغو تغییرات و خواندن دوباره…","Read the device to load settings":"برای بارگذاری تنظیمات، دستگاه را بخوانید","Only fresh, acknowledged device capabilities are shown. Application preferences are separate from board configuration.":"فقط قابلیت‌های تازه و تأییدشدهٔ دستگاه نمایش داده می‌شوند. تنظیمات برنامه از پیکربندی برد جدا هستند.","Firmware build information · read-only":"اطلاعات ساخت میان‌افزار · فقط خواندنی","UART flow-control mode, pin ownership and hardware layout are build flags, not runtime switches. Rebuild and safely update firmware to change them.":"حالت کنترل جریان UART، تخصیص پایه‌ها و چیدمان سخت‌افزار گزینه‌های زمان ساخت هستند. برای تغییر آن‌ها، میان‌افزار را دوباره بسازید و به‌صورت ایمن به‌روز کنید.","No observed build information":"اطلاعات ساخت دریافت نشده است","Build information not reported by this peer":"این دستگاه اطلاعات ساخت را گزارش نکرده است","Use the same live workspace controls; settings does not duplicate device editors.":"از کنترل‌های زندهٔ فضای کاری استفاده کنید؛ ویرایشگرهای دستگاه در تنظیمات تکرار نمی‌شوند.","Table & mapping":"میز و نگاشت","Text presentation":"نمایش متن","Buzzer & melodies":"بیزر و ملودی‌ها","Waiting for acknowledgment and saved-settings readback…":"در انتظار تأیید و بازخوانی تنظیمات ذخیره‌شده…","Settings changed on another client. Your edits are preserved; discard and reload before saving.":"تنظیمات در کلاینت دیگری تغییر کرده‌اند. ویرایش‌های شما حفظ شده‌اند؛ پیش از ذخیره، تغییرات را لغو کنید و دوباره بخوانید.","Unsaved device edits":"ویرایش‌های ذخیره‌نشدهٔ دستگاه","Observed device settings; no pending edits":"تنظیمات دستگاه دریافت شده‌اند؛ ویرایش ذخیره‌نشده‌ای نیست","Reconnect to read device settings":"برای خواندن تنظیمات دستگاه دوباره متصل شوید","Station SSID":"نام شبکهٔ ایستگاه","Open station network · no password":"شبکهٔ باز ایستگاه · بدون گذرواژه","Station enabled":"ایستگاه فعال","Recovery AP enabled":"نقطهٔ دسترسی بازیابی فعال","Scanned networks":"شبکه‌های یافت‌شده","Enable MQTT":"فعال‌کردن MQTT","Broker hostname":"نام میزبان بروکر","Base topic":"موضوع پایه","Password handling":"مدیریت گذرواژه","Replacement password":"گذرواژهٔ جایگزین","Enable outbound WebSocket":"فعال‌کردن وب‌سوکت خروجی","Server hostname":"نام میزبان سرور","Path":"مسیر","Token handling":"مدیریت توکن","Replacement token":"توکن جایگزین","Telemetry interval · seconds":"فاصلهٔ دورسنجی · ثانیه","Keep stored password":"حفظ گذرواژهٔ ذخیره‌شده","Leave password unset":"بدون گذرواژه","Keep stored token":"حفظ توکن ذخیره‌شده","Leave token unset":"بدون توکن","Wait for the current operation to finish":"منتظر پایان عملیات جاری بمانید","Saved public settings verified by device readback. Secret values remain write-only.":"تنظیمات عمومی با بازخوانی دستگاه تأیید شدند. مقادیر محرمانه فقط نوشتنی می‌مانند.","No acknowledged workspace panels":"پنل تأییدشده‌ای برای فضای کاری نیست","Reset panel layout":"بازنشانی چیدمان پنل‌ها","Show":"نمایش","System":"سیستم","Dark":"تیره","Light":"روشن","Apply":"اعمال","Sign out":"خروج از حساب","Select all":"انتخاب همه","Manage cells…":"مدیریت خانه‌ها…","Toggle without Ctrl":"انتخاب چندتایی بدون Ctrl","Animate cells":"پویانمایی خانه‌ها","LED-state indicators":"نشانگرهای وضعیت LED","Edit order":"ویرایش ترتیب","Reset order":"بازنشانی ترتیب","Cell shape":"شکل خانه","Rounded":"گوشه‌های گرد","Circle · six pixels":"دایره · شش پیکسل","Hexagon · six pixels":"شش‌ضلعی · شش پیکسل","Physical pixel indices":"شمارهٔ پیکسل‌های فیزیکی","Board modes":"حالت‌های برد","Live device":"دستگاه زنده","Clients":"کلاینت‌ها","Station":"ایستگاه","Recovery AP":"نقطهٔ دسترسی بازیابی","Cloud WebSocket":"وب‌سوکت ابری","Cell identity":"هویت خانه","Cell action":"عملیات خانه","Addressable LEDs":"LEDهای آدرس‌پذیر","Save logical / physical mapping":"ذخیرهٔ نگاشت منطقی و فیزیکی","Stream rainbow":"ارسال جریان رنگین‌کمان","Transition":"گذار","Live display":"نمایشگر زنده","Show number":"نمایش عدد","Text":"متن","Scroll short text":"پیمایش متن کوتاه","Repeat":"تکرار","Present":"نمایش متن","Present text":"نمایش متن","Stop text":"توقف متن","Inputs":"ورودی‌ها","Board identity":"هویت برد","Save board identity":"ذخیرهٔ هویت برد","Device access":"دسترسی به دستگاه","Device configuration":"پیکربندی دستگاه","Error":"خطا","Wi-Fi":"وای‌فای","Port":"درگاه","Mapping":"نگاشت","Logical":"خانهٔ منطقی","Logical cell":"خانهٔ منطقی","Physical":"خانهٔ فیزیکی","Find cells: 12, 3–8":"یافتن خانه‌ها: 12, 3–8","Search device settings":"جست‌وجوی تنظیمات دستگاه","Application":"برنامه","Device":"دستگاه","Device settings…":"تنظیمات دستگاه","Device settings":"تنظیمات دستگاه","Preferences…":"تنظیمات…","Table":"میز","Pixel diagnostics":"عیب‌یابی پیکسل‌ها","Pixel corrections":"اصلاح پیکسل‌ها","Hardware monitor":"پایش سخت‌افزار","Events":"رویدادها","Theme":"پوسته","Palette":"پالت رنگ","Control density":"تراکم کنترل‌ها","Compact":"فشرده","Regular":"معمولی","Comfortable":"راحت","Search settings":"جست‌وجوی تنظیمات","Username":"نام کاربری","Password":"گذرواژه","Refresh":"تازه‌سازی","Find cell":"یافتن خانه","Manage cells":"مدیریت خانه‌ها","Current":"کنونی","File":"پرونده","Edit":"ویرایش","Audio":"صدا","Workspace":"فضای کاری","Workspaces":"فضاهای کاری","Restore":"بازیابی","Active":"فعال","Window":"پنجره","Panels":"پنل‌ها","Language":"زبان","Touch feedback":"بازخورد لمسی","System language":"زبان سیستم","English":"انگلیسی","Persian":"فارسی","Direction":"جهت","Automatic":"خودکار","Left to right":"چپ به راست","Right to left":"راست به چپ","Left":"چپ","Right":"راست","Replacement":"جایگزین","Connect":"اتصال","Connecting…":"در حال اتصال…","Disconnect":"قطع اتصال","Identity":"هویت","Order":"ترتیب","Move up":"انتقال به بالا","Move down":"انتقال به پایین","Presentation":"نحوهٔ نمایش","Manage":"مدیریت","Publish":"انتشار","Type":"نوع","Control":"کنترل","Board":"برد","Color palette":"پالت رنگ","Studio":"استودیو","Neutral":"خنثی","New":"جدید","Description":"توضیحات","Overview":"نمای کلی","Capabilities":"قابلیت‌ها","Front panel":"پنل جلویی","Red":"قرمز","Green":"سبز","White":"سفید","Advanced":"پیشرفته","Save to board":"ذخیره در برد","Board name":"نام برد","Not persisted":"ذخیره نشده","Mode":"حالت","Pixel count":"تعداد پیکسل‌ها","pixels":"پیکسل","Single pixel":"یک پیکسل","Rainbow":"رنگین‌کمان","Color":"رنگ","Pixel":"پیکسل","Stop stream":"توقف جریان","Streaming":"در حال پخش","Idle":"آماده","Seven-segment display":"نمایشگر هفت‌بخشی","LCD display":"نمایشگر LCD","Controls":"کنترل‌ها","Page":"صفحه","Previous":"قبلی","Next":"بعدی","Decrease":"کاهش","Increase":"افزایش","Select":"انتخاب","Brightness":"روشنایی","Silent mode":"حالت بی‌صدا","No":"خیر","Connection":"اتصال","Reconnect":"اتصال دوباره","Connected":"متصل","Hold":"نگه‌داشتن","Toggle":"تغییر حالت","Segments":"نمایشگر هفت‌بخشی","Protocol":"پروتکل","Ready":"آماده","Box":"جعبه","Column":"ستون","Row":"ردیف","Button":"دکمه","Indicator":"نشانگر","Winner":"برنده","Lost":"بازنده","Transmit":"ارسال","Hide":"پنهان‌کردن","hidden":"پنهان","Lock":"قفل","Visibility":"نمایانی","Physical status RGB":"چراغ RGB فیزیکی","Telemetry":"دورسنجی","Search":"جست‌وجو","Connected board":"برد متصل","Group":"گروه","ON":"روشن","Discard":"دور انداختن","Stop preview":"توقف پیش‌نمایش","Preview":"پیش‌نمایش","Close":"بستن","Save":"ذخیره","Preferences":"تنظیمات","Unsaved changes":"تغییرات ذخیره‌نشده","Import":"درون‌ریزی","Export":"برون‌ریزی","Target":"مقصد","Disabled":"غیرفعال","Copy":"رونوشت","Status":"وضعیت","Server":"سرور","Open":"باز کردن","Cancel":"انصراف","Action":"عملکرد","Enabled":"فعال","Sound":"صدا","Appearance":"ظاهر","Pealayer green":"سبز پی‌لیر","Windows blue":"آبی ویندوز","macOS blue":"آبی مک‌اواس","Custom":"سفارشی","Custom accent":"رنگ تأکیدی سفارشی","Reset":"بازنشانی","Icon":"نماد","Presets":"گزینه‌های آماده","Name":"نام","On":"روشن","Off":"خاموش","Actions":"عملیات","cues":"نشانه","selected":"انتخاب‌شده","Source":"منبع","Output":"خروجی","Name:":"نام:","Effect":"جلوه","Play":"پخش","Pause":"مکث","Stop":"توقف","Mute":"قطع صدا","Unknown":"ناشناخته","End":"پایان","Step":"پله‌ای","Time":"زمان","Value":"مقدار","Enable rule":"فعال‌کردن قاعده","Correction rules":"قواعد اصلاح پیکسل","Arrange panels":"چیدمان پنل‌ها","Done":"پایان","Fit":"اندازهٔ پنل","Fit table to panel":"اندازهٔ میز برابر با پنل","Zoom out":"کوچک‌نمایی","Zoom in":"بزرگ‌نمایی","Go to":"رفتن به","Go to panel":"رفتن به پنل","Select {state}":"انتخاب {state}","LED transmitter":"فرستنده LED","GPIO · simple":"GPIO · ساده","RMT + DMA":"RMT + DMA","No active transmitter":"فرستنده فعال نیست","Switching to {backend}…":"در حال تغییر به {backend}…","Active: {backend}":"فعال: {backend}","Transmitter not ready · {backend}":"فرستنده آماده نیست · {backend}","LED transmitter change failed · {error} · active: {backend}":"تغییر فرستنده LED ناموفق بود · {error} · فعال: {backend}","LED transmitter change was accepted, but active state is unconfirmed. No command was replayed.":"تغییر فرستنده LED پذیرفته شد، اما وضعیت فعال تأیید نشده است. فرمان دوباره ارسال نشد.","Device session changed; command was not replayed":"نشست دستگاه تغییر کرد؛ فرمان دوباره ارسال نشد","All panels":"همهٔ پنل‌ها","Read-only":"فقط خواندنی","Lock lost cells":"قفل خانه‌های بازنده","Export JSON":"خروجی JSON","Export CSV":"خروجی CSV","Apply assignments":"اعمال رنگ‌های تخصیص‌یافته","Cell":"خانه","Language & input":"زبان و ورودی","Preferences · This browser":"تنظیمات · این مرورگر","Close appearance":"بستن تنظیمات ظاهر","Apply preferences":"اعمال تنظیمات","Accent":"رنگ تأکیدی","System / default blue":"سیستم / آبی پیش‌فرض","System follows this browser’s light/dark setting.":"حالت سیستم از تنظیم روشن یا تیرهٔ این مرورگر پیروی می‌کند.","Actual shared Pealayer palette definitions.":"پالت‌های رنگ مشترک با پی‌لیر.","System uses blue in this standalone device UI.":"رنگ پیش‌فرض سیستم در این رابط دستگاه، آبی است.","Button text contrast is selected automatically.":"رنگ متن دکمه برای خوانایی، خودکار انتخاب می‌شود.","Silent mode ·":"حالت بی‌صدا ·","Use your configured device credentials.":"مشخصات ورود تنظیم‌شدهٔ دستگاه را وارد کنید.","Sign in":"ورود","Connecting to device…":"در حال اتصال به دستگاه…","Retry connection":"تلاش دوباره برای اتصال","Device access storage is unavailable. USB recovery is required.":"ذخیره‌سازی دسترسی دستگاه در دسترس نیست. بازیابی با USB لازم است.","Connection unavailable. Retry to read the device.":"اتصال در دسترس نیست. برای خواندن دستگاه دوباره تلاش کنید.","Sign in to this device.":"به این دستگاه وارد شوید.","First login: admin / pakarcontroller. Change these in Device access after signing in.":"ورود نخست: admin / pakarcontroller. پس از ورود، این مقادیر را در بخش دسترسی دستگاه تغییر دهید.","AtomicDeploy control surface":"پنل کنترل AtomicDeploy","WebSocket —":"وب‌سوکت —","WebSocket retrying":"تلاش دوباره برای وب‌سوکت","WebSocket live":"وب‌سوکت زنده","Reconnect WebSocket":"اتصال دوبارهٔ وب‌سوکت","Disconnect · use HTTP":"قطع وب‌سوکت · استفاده از HTTP","HTTP refresh · WS off":"تازه‌سازی HTTP · وب‌سوکت خاموش","HTTP status refresh continues. USB transport is managed by the desktop app.":"وضعیت از طریق HTTP تازه‌سازی می‌شود. اتصال USB در برنامهٔ دسکتاپ مدیریت می‌شود.","Panels ▾":"پنل‌ها ▾","Table board":"برد میز","Controller":"کنترلر","Table presentation":"نمایش میز","Game":"بازی","Cell selection":"انتخاب خانه‌ها","First wins":"اولین فشردن برنده است","Waiting for board capabilities…":"در انتظار قابلیت‌های برد…","Capabilities unavailable or stale. Connect to a compatible board.":"قابلیت‌ها در دسترس یا به‌روز نیستند. به برد سازگار متصل شوید.","Read-only: refresh to obtain a supported board/boot identity":"فقط خواندنی: برای دریافت هویت معتبر برد و راه‌اندازی، تازه‌سازی کنید","Top first · CCW · indices 0–479 · sampled RGB before brightness":"شروع از بالا · پادساعت‌گرد · شماره‌های ۰ تا ۴۷۹ · RGB نمونه‌برداری‌شده پیش از اعمال روشنایی","Click selects · Ctrl toggles · Shift extends · drag selects · Enter manages":"کلیک: انتخاب · Ctrl: تغییر انتخاب · Shift: گسترش · کشیدن: انتخاب محدوده · Enter: مدیریت","Find logical cells":"یافتن خانه‌های منطقی","80 logical cells":"۸۰ خانهٔ منطقی","Clear selection":"پاک‌کردن انتخاب","State":"وضعیت","Fade":"تغییر تدریجی","Immediate":"آنی","fade":"تغییر تدریجی","immediate":"آنی","pulse":"تپش","none":"بدون جلوه","rainbow":"رنگین‌کمان","fire":"آتش","Once":"یک بار","Loop":"تکرار پیوسته","Interval":"با فاصله","Buzzer":"بیزر","Play cue":"پخش ملودی","Available melody":"ملودی‌های موجود","Live TM1637 preview":"پیش‌نمایش زندهٔ TM1637","Last complete LCD frame":"آخرین تصویر کامل LCD","Display text":"متن نمایشگر","UART errors":"خطاهای UART","Online":"متصل","Offline":"قطع‌شده","Not responding":"بدون پاسخ","Waiting":"در انتظار","Offline ({code})":"قطع‌شده ({code})","Row {row}":"ردیف {row}","Select row {row}":"انتخاب ردیف {row}","{state} · {count} cells":"{state} · {count} خانه","Static":"ثابت","Effects":"جلوه‌ها","Rainbow test":"آزمایش رنگین‌کمان","LED driver: {driver}":"راه‌انداز LED: {driver}","Static cells: six matching pixels. Rainbow test animates explicitly.":"خانه‌های ثابت: شش پیکسل هم‌رنگ. آزمایش رنگین‌کمان، پویانمایی را فعال می‌کند.","Effects: animated output enabled explicitly.":"جلوه‌ها: خروجی متحرک فعال شده است.","Logical cell {id}":"خانهٔ منطقی {id}","Collapse or expand {panel}":"جمع یا باز کردن {panel}","Physical pixel · 1..480":"پیکسل فیزیکی · ۱ تا ۴۸۰","Physical pixel index from 1 to 480":"شمارهٔ پیکسل فیزیکی از ۱ تا ۴۸۰","Previous pixel":"پیکسل قبلی","Show white dot":"نمایش نقطهٔ سفید","Next pixel":"پیکسل بعدی","One physical pixel white, all others black. Explicitly selects effects mode; no animation. Enable LED output first.":"یک پیکسل فیزیکی سفید و بقیه خاموش می‌شوند. حالت جلوه‌ها بدون پویانمایی انتخاب می‌شود. ابتدا خروجی LED را فعال کنید.","RGB unavailable":"RGB در دسترس نیست","Close cell manager":"بستن مدیریت خانه‌ها","Choose rows or cells, then apply a state and transition to the selection. Ctrl toggles, Shift extends. Selection never changes LEDs.":"ردیف‌ها یا خانه‌ها را انتخاب کنید، سپس وضعیت و گذار را اعمال کنید. Ctrl انتخاب را تغییر می‌دهد و Shift آن را گسترش می‌دهد. انتخاب به‌تنهایی LEDها را تغییر نمی‌دهد.","Edit order · drag before/after a cell; IDs and wiring stay unchanged":"ویرایش ترتیب · خانه را به قبل یا بعد از خانه‌ای بکشید؛ شناسه‌ها و سیم‌کشی تغییر نمی‌کنند","Apply updates all selected cells together.":"اعمال، همهٔ خانه‌های انتخاب‌شده را با هم به‌روزرسانی می‌کند.","Apply to selection":"اعمال به انتخاب","Board display name":"نام نمایشی برد","Hostname":"نام میزبان","Living room table":"میز اتاق نشیمن","Application branding comes from the running firmware. Board names are saved across OTA. Only hostname changes restart Wi-Fi/AP; passwords are unchanged.":"هویت برنامه از میان‌افزار فعال دریافت می‌شود. نام برد پس از OTA حفظ می‌شود. فقط تغییر نام میزبان، وای‌فای و نقطهٔ دسترسی را راه‌اندازی مجدد می‌کند؛ گذرواژه‌ها تغییر نمی‌کنند.","Wi-Fi & Ethernet":"وای‌فای و اترنت","Scan for networks":"جست‌وجوی شبکه‌ها","Scan":"جست‌وجو","Enter SSID or choose a scanned network":"نام شبکه را وارد یا از نتیجهٔ جست‌وجو انتخاب کنید","Station password (re-enter on save)":"گذرواژهٔ ایستگاه","Replacement password · blank keeps stored":"گذرواژهٔ جایگزین · خالی یعنی حفظ مقدار ذخیره‌شده","Automatic IPv4 (DHCP)":"آدرس‌دهی خودکار IPv4 (DHCP)","IPv4 address":"نشانی IPv4","Gateway":"دروازهٔ شبکه","Subnet mask":"ماسک زیرشبکه","Primary DNS":"DNS اصلی","Secondary DNS":"DNS دوم","Leave the password blank to keep it for the unchanged SSID. Explicitly choose Open for a passwordless network. Addressing controls appear only when reported by this firmware.":"برای حفظ گذرواژهٔ همان شبکه، این قسمت را خالی بگذارید. برای شبکهٔ بدون گذرواژه، گزینهٔ شبکهٔ باز را انتخاب کنید. تنظیمات نشانی فقط در صورت پشتیبانی میان‌افزار نمایش داده می‌شوند.","Save & verify Wi-Fi":"ذخیره و تأیید وای‌فای","Cloud links":"ارتباطات ابری","Replace password":"جایگزینی گذرواژه","Clear stored password":"پاک‌کردن گذرواژهٔ ذخیره‌شده","Replace token":"جایگزینی توکن","Clear stored token":"پاک‌کردن توکن ذخیره‌شده","Clear token":"پاک‌کردن توکن","Save & verify cloud":"ذخیره و تأیید ارتباطات ابری","Reconnect cloud links":"اتصال دوبارهٔ ارتباطات ابری","TLS validates endpoints against ISRG Root X1. MQTT commands use //command; status and availability are retained.":"TLS گواهی مقصد را با ISRG Root X1 اعتبارسنجی می‌کند. مسیر فرمان MQTT برابر //command است؛ وضعیت و دسترس‌پذیری نگه‌داری می‌شوند.","Require authentication":"نیاز به احراز هویت","Current password":"گذرواژهٔ کنونی","New username":"نام کاربری جدید","New password":"گذرواژهٔ جدید","Confirm password":"تأیید گذرواژه","Save device access":"ذخیرهٔ دسترسی دستگاه","Saved on the ESP and retained across firmware OTA. Authentication is off unless enabled here. Changes apply to API, WebSocket and Telnet and reconnect all clients. It does not change the Wi-Fi password.":"روی ESP ذخیره و پس از OTA حفظ می‌شود. احراز هویت تا زمانی که اینجا فعال نشود خاموش است. تغییرات روی API، وب‌سوکت و تلنت اعمال می‌شوند و کلاینت‌ها دوباره متصل می‌شوند. گذرواژهٔ وای‌فای تغییر نمی‌کند.","Wired Ethernet":"اترنت سیمی","Ethernet unavailable":"اترنت در دسترس نیست","Enable Ethernet":"فعال‌کردن اترنت","Automatic Ethernet addressing (DHCP)":"آدرس‌دهی خودکار اترنت (DHCP)","Advertise link identity (LLDP)":"اعلام هویت پیوند (LLDP)","Advanced: receive LLDP neighbors (promiscuous Ethernet)":"پیشرفته: دریافت همسایه‌های LLDP (اترنت فراگیر)","Read / discard Ethernet draft":"خواندن و لغو پیش‌نویس اترنت","Save Ethernet and verify":"ذخیره و تأیید اترنت","IPv4 ready · {ip}":"IPv4 آماده · {ip}","Link up · waiting for IPv4":"پیوند برقرار · در انتظار IPv4","Link down":"پیوند قطع است","Firmware notifications · ntfy":"اعلان‌های میان‌افزار · ntfy","Send firmware-update notifications (opt in)":"ارسال اعلان‌های به‌روزرسانی میان‌افزار (اختیاری)","HTTPS server":"سرور HTTPS","Private topic":"موضوع خصوصی","Optional bearer token":"توکن دسترسی اختیاری","Disabled by default. Major stages and results only; flashing never waits for delivery. No ntfy message can trigger an update.":"پیش‌فرض خاموش است. فقط مراحل مهم و نتیجه‌ها ارسال می‌شوند؛ فلش‌کردن منتظر تحویل اعلان نمی‌ماند. هیچ پیام ntfy نمی‌تواند به‌روزرسانی را شروع کند.","Save notifications and verify":"ذخیره و تأیید اعلان‌ها","Onboard semantic status RGB":"چراغ RGB وضعیت روی برد","Separate status pixel · board-controlled animation.":"پیکسل وضعیت مستقل · پویانمایی با کنترل برد.","Onboard pixel GPIO":"پایهٔ پیکسل روی برد","Unassigned":"تخصیص‌نیافته","GPIO48 · common DevKit":"GPIO48 · DevKit رایج","GPIO38 · alternate DevKit":"GPIO38 · DevKit جایگزین","Confirm this is the onboard addressable RGB pin":"تأیید می‌کنم این پایهٔ RGB آدرس‌پذیر روی برد است","Enable semantic status output":"فعال‌کردن خروجی وضعیت معنایی","Status RGB brightness":"روشنایی RGB وضعیت","Read / discard draft":"خواندن و لغو پیش‌نویس","Save onboard RGB":"ذخیرهٔ RGB روی برد","Read settings before editing. Preview shows reported RGB.":"پیش از ویرایش، تنظیمات را بخوانید. پیش‌نمایش، RGB گزارش‌شده را نشان می‌دهد.","Verified remote update":"به‌روزرسانی تأییدشدهٔ راه دور","Manifest HTTPS URL":"نشانی HTTPS مانیفست","Manifest SHA-256":"SHA-256 مانیفست","Endpoint bearer (optional)":"توکن دسترسی مقصد (اختیاری)","Both boards":"هر دو برد","ESP (same-version repair)":"ESP (ترمیم همان نسخه)","AVR (same-version repair)":"AVR (ترمیم همان نسخه)","Download & verify only":"فقط بارگیری و اعتبارسنجی","Refresh job":"تازه‌سازی عملیات","Apply verified job…":"اعمال عملیات تأییدشده…","Cancel job":"لغو عملیات","Exact SHA-256, profile and version checks. Applying preserves and verifies AVR EEPROM. Two-board updates are not atomic across power loss; uncertain outcomes keep Prog armed. No automatic retry.":"SHA-256، پروفایل و نسخه دقیقاً بررسی می‌شوند. EEPROM برد AVR حفظ و تأیید می‌شود. قطع برق ممکن است به‌روزرسانی دو برد را ناقص کند؛ در نتیجهٔ نامعلوم، Prog فعال می‌ماند. تلاش دوبارهٔ خودکار انجام نمی‌شود.","No job observed":"عملیاتی دریافت نشده است","Device & client telemetry":"دورسنجی دستگاه و کلاینت‌ها","Read complete snapshot":"خواندن وضعیت کامل","Firmware vault":"مخزن میان‌افزار","Store AVR image":"ذخیرهٔ تصویر AVR","Back up AVR":"پشتیبان‌گیری از AVR","Program + verify":"برنامه‌ریزی و تأیید","Flash backup":"پشتیبان فلش","ESP self-OTA":"OTA خود ESP","Target FPS":"نرخ فریم هدف","Buffered frames":"فریم‌های بافرشده","Streaming target FPS":"نرخ فریم هدف پخش","Maximum buffered stream frames":"حداکثر فریم‌های بافرشدهٔ پخش","Browser policy · 0 is uncapped · skip stale frames under backpressure. Sends are not measured LED refresh.":"سیاست مرورگر · صفر بدون سقف است · هنگام فشار بافر، فریم‌های قدیمی رد می‌شوند. ارسال‌ها معیار نرخ تازه‌سازی LED نیستند.","No browser stream started":"پخش مرورگر آغاز نشده است","Browser sends":"ارسال‌های مرورگر","Stopped":"متوقف شد","Stopped: peer, capability or connection changed":"متوقف شد: دستگاه، قابلیت یا اتصال تغییر کرد","Stopped: socket buffering is unknown":"متوقف شد: وضعیت بافر سوکت نامشخص است","Stopped: partial frame outcome unknown; not replayed":"متوقف شد: نتیجهٔ فریم ناقص نامشخص است؛ دوباره ارسال نمی‌شود","Stopped: send failed or policy invalid; outcome unknown, not replayed":"متوقف شد: ارسال ناموفق یا سیاست نامعتبر است؛ نتیجه نامشخص است و دوباره ارسال نمی‌شود","Stopped: page owner disposed":"متوقف شد: مالک صفحه پایان یافت","Choose 0–240 FPS and 1–16 buffered frames. 0 means uncapped.":"نرخ فریم ۰ تا ۲۴۰ و بافر ۱ تا ۱۶ فریم را انتخاب کنید. صفر بدون سقف است.","Connect to an acknowledged streaming board first":"ابتدا به بردی با قابلیت پخش تأییدشده متصل شوید","Stream owner disposed":"مالک پخش پایان یافته است","{state} · {frames} frames · rate pending · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · نرخ در انتظار · {skipped} ردشده · {bytes} بایت در صف سوکت","{state} · {frames} frames · {rate} FPS average · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · میانگین {rate} فریم بر ثانیه · {skipped} ردشده · {bytes} بایت در صف سوکت","Dismiss {title}":"بستن {title}","{count} selected":"{count} انتخاب‌شده","This board has not acknowledged atomic cell batches. No per-cell fallback was sent.":"این برد فرمان گروهی یکپارچهٔ خانه‌ها را تأیید نکرده است؛ فرمان‌های جداگانه ارسال نشدند.","Sending one board command for {total} cells; sent writes cannot be cancelled.":"یک فرمان برای {total} خانه به برد ارسال می‌شود؛ فرمان ارسال‌شده قابل لغو نیست.","Device confirmed colors for {total} cells":"رنگ {total} خانه توسط دستگاه تأیید شد","Status LED settings saved on device":"تنظیمات چراغ وضعیت روی دستگاه ذخیره شد","Outcome unknown. The single command may have applied; it was not retried. Refresh and inspect.":"نتیجه نامشخص است. ممکن است فرمان اعمال شده باشد؛ دوباره ارسال نشد. تازه‌سازی و بررسی کنید.","No command was sent. Refresh before a new action.":"فرمانی ارسال نشد. پیش از اقدام بعدی تازه‌سازی کنید.","Pixel groups":"گروه‌های پیکسل","Whole group":"تمام گروه","Customize group":"تعداد سفارشی گروه","Physical group":"گروه فیزیکی","Physical pixels":"پیکسل‌های فیزیکی","Use default":"استفاده از پیش‌فرض","All groups use the default pixel count":"تمام گروه‌ها از تعداد پیکسل پیش‌فرض استفاده می‌کنند","Decrease pixel count":"کاهش تعداد پیکسل","Increase pixel count":"افزایش تعداد پیکسل","Discard correction edits?":"ویرایش‌های اصلاح پیکسل کنار گذاشته شوند؟","Discard pixel group edits?":"ویرایش‌های گروه پیکسل کنار گذاشته شوند؟","Enter a pixel count from 1 to 255":"تعداد پیکسل را از ۱ تا ۲۵۵ وارد کنید","Reload keeps the saved board configuration and discards only these edits.":"بارگذاری مجدد، تنظیمات ذخیره‌شدهٔ برد را نگه می‌دارد و فقط این ویرایش‌ها را کنار می‌گذارد.","Correction confirmation is unavailable. Edits were kept.":"پنجرهٔ تأیید اصلاحات در دسترس نیست. ویرایش‌ها حفظ شدند.","Device session changed. Edits were kept.":"نشست دستگاه تغییر کرد. ویرایش‌ها حفظ شدند.","Decrease pixel":"کاهش شمارهٔ پیکسل","Increase pixel":"افزایش شمارهٔ پیکسل","Decrease offset":"کاهش جابه‌جایی","Increase offset":"افزایش جابه‌جایی","Complete the pixel address":"نشانی پیکسل را کامل کنید","Complete prior rules to locate source":"برای تعیین مبدأ، قواعد قبلی را کامل کنید","Black from prior rule · no logical source":"خاموش‌شده با قاعدهٔ قبلی · بدون مبدأ منطقی","Outside assigned groups":"خارج از گروه‌های تخصیص‌یافته","Logical address not current":"نشانی منطقی به‌روز نیست","Logical address unavailable":"نشانی منطقی در دسترس نیست","Logical cell unavailable":"خانهٔ منطقی در دسترس نیست","Wire":"نشانی خروجی","Raw":"خام","Use selected cursor":"استفاده از مکان‌نمای انتخاب‌شده","Use selected cursor ({pixel})":"استفاده از مکان‌نمای انتخاب‌شده ({pixel})","Wire map":"نگاشت مسیر پیکسل","Applied map":"نگاشت اعمال‌شده","Draft map":"نگاشت پیش‌نویس","Draft pixel mapping":"نگاشت پیش‌نویس پیکسل‌ها","Find physical pixel in wire map":"یافتن پیکسل فیزیکی در نگاشت مسیر","Local preview · not applied":"پیش‌نمایش محلی · اعمال نشده","Complete invalid values to preview the draft":"برای پیش‌نمایش پیش‌نویس، مقدارهای نامعتبر را کامل کنید","Applied mapping unavailable":"نگاشت اعمال‌شده در دسترس نیست","No matching pixels":"پیکسل مطابقی پیدا نشد","Advanced probe":"تنظیمات پیشرفتهٔ آزمون پیکسل","Probe effect":"جلوهٔ آزمون پیکسل","Composition":"ترکیب با صحنه","Solid":"ثابت","Flash":"چشمک‌زن","Breathing":"تنفسی","Solo":"مستقل","Overlay":"روی صحنه","Period · ms":"دوره · میلی‌ثانیه","On time · %":"زمان روشن · درصد","Minimum brightness":"کمینهٔ روشنایی","Maximum brightness":"بیشینهٔ روشنایی","Complete diagnostic timing and brightness":"زمان‌بندی و روشنایی آزمون را کامل کنید","Invalid diagnostic options":"تنظیمات آزمون نامعتبر است","Invalid diagnostic timing or brightness":"زمان‌بندی یا روشنایی آزمون نامعتبر است","Minimum brightness exceeds maximum":"کمینهٔ روشنایی از بیشینه بیشتر است","Diagnostic effects were not acknowledged":"پشتیبانی جلوه‌های آزمون تأیید نشده است","Diagnostic overlay was not acknowledged":"پشتیبانی آزمون روی صحنه تأیید نشده است","Board reports probe mask off":"برد خاموشی محدودهٔ آزمون را گزارش می‌کند","Diagnostic frame publication is not confirmed":"انتشار فریم آزمون تأیید نشده است","Rule preview":"پیش‌نمایش قاعده","Send black to this address. Later pixels keep their positions.":"این نشانی خاموش می‌شود و پیکسل‌های بعدی در جای خود می‌مانند.","Skip this value. Following values move one address earlier; the last address is black.":"این مقدار حذف می‌شود؛ مقدارهای بعدی یک نشانی جلو می‌آیند و نشانی آخر خاموش می‌شود.","Move this pixel and all following values. Positive moves later; negative moves earlier. Empty addresses are black.":"این پیکسل و مقدارهای پس از آن جابه‌جا می‌شوند؛ مثبت به عقب و منفی به جلو. نشانی‌های خالی خاموش می‌شوند."}; +const PAKAR_FA={"Disconnected":"قطع‌شده","Hardware":"سخت‌افزار","Network":"شبکه","Pulse":"تپش","Revision":"بازنگری","Import colors…":"ورود رنگ‌ها…","Import cell colors":"ورود رنگ خانه‌ها","Imported":"واردشده","Board sound":"صدای برد","AVR name":"نام AVR","Mutes the physical board buzzer, including welcome and menu sounds. Saves directly on the board; browser sound is separate.":"بیزر خود برد، از جمله صدای خوشامد و منوها را بی‌صدا می‌کند. مستقیم روی برد ذخیره می‌شود؛ صدای مرورگر جدا است.","Appearance is saved in this browser. Board sound changes save directly to the board.":"ظاهر در این مرورگر ذخیره می‌شود. تغییرات صدای برد مستقیم روی برد ذخیره می‌شوند.","Programming safety keeps the board muted.":"حفاظت هنگام برنامه‌ریزی، برد را بی‌صدا نگه می‌دارد.","Verified · saved in EEPROM":"تأیید شد · ذخیره‌شده در EEPROM","Reading board…":"در حال خواندن برد…","LEDs & cells":"LEDها و خانه‌ها","AVR & inputs":"AVR و ورودی‌ها","Firmware & storage":"میان‌افزار و ذخیره‌سازی","Search settings…":"جست‌وجوی تنظیمات…","Close device settings":"بستن تنظیمات دستگاه","Device settings pages":"صفحه‌های تنظیمات دستگاه","Discard & reload…":"لغو تغییرات و خواندن دوباره…","Read the device to load settings":"برای بارگذاری تنظیمات، دستگاه را بخوانید","Only fresh, acknowledged device capabilities are shown. Application preferences are separate from board configuration.":"فقط قابلیت‌های تازه و تأییدشدهٔ دستگاه نمایش داده می‌شوند. تنظیمات برنامه از پیکربندی برد جدا هستند.","Firmware build information · read-only":"اطلاعات ساخت میان‌افزار · فقط خواندنی","UART flow-control mode, pin ownership and hardware layout are build flags, not runtime switches. Rebuild and safely update firmware to change them.":"حالت کنترل جریان UART، تخصیص پایه‌ها و چیدمان سخت‌افزار گزینه‌های زمان ساخت هستند. برای تغییر آن‌ها، میان‌افزار را دوباره بسازید و به‌صورت ایمن به‌روز کنید.","No observed build information":"اطلاعات ساخت دریافت نشده است","Build information not reported by this peer":"این دستگاه اطلاعات ساخت را گزارش نکرده است","Use the same live workspace controls; settings does not duplicate device editors.":"از کنترل‌های زندهٔ فضای کاری استفاده کنید؛ ویرایشگرهای دستگاه در تنظیمات تکرار نمی‌شوند.","Table & mapping":"میز و نگاشت","Text presentation":"نمایش متن","Buzzer & melodies":"بیزر و ملودی‌ها","Waiting for acknowledgment and saved-settings readback…":"در انتظار تأیید و بازخوانی تنظیمات ذخیره‌شده…","Settings changed on another client. Your edits are preserved; discard and reload before saving.":"تنظیمات در کلاینت دیگری تغییر کرده‌اند. ویرایش‌های شما حفظ شده‌اند؛ پیش از ذخیره، تغییرات را لغو کنید و دوباره بخوانید.","Unsaved device edits":"ویرایش‌های ذخیره‌نشدهٔ دستگاه","Observed device settings; no pending edits":"تنظیمات دستگاه دریافت شده‌اند؛ ویرایش ذخیره‌نشده‌ای نیست","Reconnect to read device settings":"برای خواندن تنظیمات دستگاه دوباره متصل شوید","Station SSID":"نام شبکهٔ ایستگاه","Open station network · no password":"شبکهٔ باز ایستگاه · بدون گذرواژه","Station enabled":"ایستگاه فعال","Recovery AP enabled":"نقطهٔ دسترسی بازیابی فعال","Scanned networks":"شبکه‌های یافت‌شده","Enable MQTT":"فعال‌کردن MQTT","Broker hostname":"نام میزبان بروکر","Base topic":"موضوع پایه","Password handling":"مدیریت گذرواژه","Replacement password":"گذرواژهٔ جایگزین","Enable outbound WebSocket":"فعال‌کردن وب‌سوکت خروجی","Server hostname":"نام میزبان سرور","Path":"مسیر","Token handling":"مدیریت توکن","Replacement token":"توکن جایگزین","Telemetry interval · seconds":"فاصلهٔ دورسنجی · ثانیه","Keep stored password":"حفظ گذرواژهٔ ذخیره‌شده","Leave password unset":"بدون گذرواژه","Keep stored token":"حفظ توکن ذخیره‌شده","Leave token unset":"بدون توکن","Wait for the current operation to finish":"منتظر پایان عملیات جاری بمانید","Saved public settings verified by device readback. Secret values remain write-only.":"تنظیمات عمومی با بازخوانی دستگاه تأیید شدند. مقادیر محرمانه فقط نوشتنی می‌مانند.","No acknowledged workspace panels":"پنل تأییدشده‌ای برای فضای کاری نیست","Reset panel layout":"بازنشانی چیدمان پنل‌ها","Show":"نمایش","System":"سیستم","Dark":"تیره","Light":"روشن","Apply":"اعمال","Sign out":"خروج از حساب","Select all":"انتخاب همه","Manage cells…":"مدیریت خانه‌ها…","Toggle without Ctrl":"انتخاب چندتایی بدون Ctrl","Animate cells":"پویانمایی خانه‌ها","LED-state indicators":"نشانگرهای وضعیت LED","Edit order":"ویرایش ترتیب","Reset order":"بازنشانی ترتیب","Cell shape":"شکل خانه","Rounded":"گوشه‌های گرد","Circle · six pixels":"دایره · شش پیکسل","Hexagon · six pixels":"شش‌ضلعی · شش پیکسل","Physical pixel indices":"شمارهٔ پیکسل‌های فیزیکی","Board modes":"حالت‌های برد","Live device":"دستگاه زنده","Clients":"کلاینت‌ها","Station":"ایستگاه","Recovery AP":"نقطهٔ دسترسی بازیابی","Cloud WebSocket":"وب‌سوکت ابری","Cell identity":"هویت خانه","Cell action":"عملیات خانه","Addressable LEDs":"LEDهای آدرس‌پذیر","Save logical / physical mapping":"ذخیرهٔ نگاشت منطقی و فیزیکی","Stream rainbow":"ارسال جریان رنگین‌کمان","Transition":"گذار","Live display":"نمایشگر زنده","Show number":"نمایش عدد","Text":"متن","Scroll short text":"پیمایش متن کوتاه","Repeat":"تکرار","Present":"نمایش متن","Present text":"نمایش متن","Stop text":"توقف متن","Inputs":"ورودی‌ها","Board identity":"هویت برد","Save board identity":"ذخیرهٔ هویت برد","Device access":"دسترسی به دستگاه","Device configuration":"پیکربندی دستگاه","Error":"خطا","Wi-Fi":"وای‌فای","Port":"درگاه","Mapping":"نگاشت","Logical":"خانهٔ منطقی","Logical cell":"خانهٔ منطقی","Physical":"خانهٔ فیزیکی","Find cells: 12, 3–8":"یافتن خانه‌ها: 12, 3–8","Search device settings":"جست‌وجوی تنظیمات دستگاه","Application":"برنامه","Device":"دستگاه","Device settings…":"تنظیمات دستگاه","Device settings":"تنظیمات دستگاه","Preferences…":"تنظیمات…","Table":"میز","Pixel diagnostics":"عیب‌یابی پیکسل‌ها","Pixel corrections":"اصلاح پیکسل‌ها","Hardware monitor":"پایش سخت‌افزار","Events":"رویدادها","Theme":"پوسته","Palette":"پالت رنگ","Control density":"تراکم کنترل‌ها","Compact":"فشرده","Regular":"معمولی","Comfortable":"راحت","Search settings":"جست‌وجوی تنظیمات","Username":"نام کاربری","Password":"گذرواژه","Refresh":"تازه‌سازی","Find cell":"یافتن خانه","Manage cells":"مدیریت خانه‌ها","Current":"کنونی","File":"پرونده","Edit":"ویرایش","Audio":"صدا","Workspace":"فضای کاری","Workspaces":"فضاهای کاری","Restore":"بازیابی","Active":"فعال","Window":"پنجره","Panels":"پنل‌ها","Language":"زبان","Touch feedback":"بازخورد لمسی","System language":"زبان سیستم","English":"انگلیسی","Persian":"فارسی","Direction":"جهت","Automatic":"خودکار","Left to right":"چپ به راست","Right to left":"راست به چپ","Left":"چپ","Right":"راست","Replacement":"جایگزین","Connect":"اتصال","Connecting…":"در حال اتصال…","Disconnect":"قطع اتصال","Identity":"هویت","Order":"ترتیب","Move up":"انتقال به بالا","Move down":"انتقال به پایین","Presentation":"نحوهٔ نمایش","Manage":"مدیریت","Publish":"انتشار","Type":"نوع","Control":"کنترل","Board":"برد","Color palette":"پالت رنگ","Studio":"استودیو","Neutral":"خنثی","New":"جدید","Description":"توضیحات","Overview":"نمای کلی","Capabilities":"قابلیت‌ها","Front panel":"پنل جلویی","Red":"قرمز","Green":"سبز","White":"سفید","Advanced":"پیشرفته","Save to board":"ذخیره در برد","Board name":"نام برد","Not persisted":"ذخیره نشده","Mode":"حالت","Pixel count":"تعداد پیکسل‌ها","pixels":"پیکسل","Single pixel":"یک پیکسل","Rainbow":"رنگین‌کمان","Color":"رنگ","Pixel":"پیکسل","Stop stream":"توقف جریان","Streaming":"در حال پخش","Idle":"آماده","notes":"نت","Unavailable":"در دسترس نیست","Seven-segment display":"نمایشگر هفت‌بخشی","LCD display":"نمایشگر LCD","Controls":"کنترل‌ها","Page":"صفحه","Previous":"قبلی","Next":"بعدی","Decrease":"کاهش","Increase":"افزایش","Select":"انتخاب","Brightness":"روشنایی","Silent mode":"حالت بی‌صدا","No":"خیر","Connection":"اتصال","Reconnect":"اتصال دوباره","Connected":"متصل","Hold":"نگه‌داشتن","Toggle":"تغییر حالت","Segments":"نمایشگر هفت‌بخشی","Protocol":"پروتکل","Ready":"آماده","Box":"جعبه","Column":"ستون","Row":"ردیف","Button":"دکمه","Indicator":"نشانگر","Winner":"برنده","Lost":"بازنده","Transmit":"ارسال","Hide":"پنهان‌کردن","hidden":"پنهان","Lock":"قفل","Visibility":"نمایانی","Physical status RGB":"چراغ RGB فیزیکی","Telemetry":"دورسنجی","Search":"جست‌وجو","Connected board":"برد متصل","Group":"گروه","ON":"روشن","Release":"رهاسازی","Discard":"دور انداختن","Stop preview":"توقف پیش‌نمایش","Preview":"پیش‌نمایش","Close":"بستن","Save":"ذخیره","Preferences":"تنظیمات","Unsaved changes":"تغییرات ذخیره‌نشده","Import":"درون‌ریزی","Export":"برون‌ریزی","Target":"مقصد","Disabled":"غیرفعال","Copy":"رونوشت","Status":"وضعیت","Server":"سرور","Open":"باز کردن","Cancel":"انصراف","Action":"عملکرد","Enabled":"فعال","Sound":"صدا","Appearance":"ظاهر","Pealayer green":"سبز پی‌لیر","Windows blue":"آبی ویندوز","macOS blue":"آبی مک‌اواس","Custom":"سفارشی","Custom accent":"رنگ تأکیدی سفارشی","Reset":"بازنشانی","Icon":"نماد","Presets":"گزینه‌های آماده","Name":"نام","On":"روشن","Off":"خاموش","Actions":"عملیات","cues":"نشانه","selected":"انتخاب‌شده","Source":"منبع","Output":"خروجی","Name:":"نام:","Effect":"جلوه","Play":"پخش","Pause":"مکث","Stop":"توقف","Mute":"قطع صدا","Unknown":"ناشناخته","End":"پایان","Step":"پله‌ای","Time":"زمان","Value":"مقدار","Enable rule":"فعال‌کردن قاعده","Correction rules":"قواعد اصلاح پیکسل","Arrange panels":"چیدمان پنل‌ها","Done":"پایان","Fit":"اندازهٔ پنل","Fit table to panel":"اندازهٔ میز برابر با پنل","Zoom out":"کوچک‌نمایی","Zoom in":"بزرگ‌نمایی","Go to":"رفتن به","Go to panel":"رفتن به پنل","Select {state}":"انتخاب {state}","LED transmitter":"فرستنده LED","GPIO · simple":"GPIO · ساده","RMT + DMA":"RMT + DMA","No active transmitter":"فرستنده فعال نیست","Switching to {backend}…":"در حال تغییر به {backend}…","Active: {backend}":"فعال: {backend}","Transmitter not ready · {backend}":"فرستنده آماده نیست · {backend}","LED transmitter change failed · {error} · active: {backend}":"تغییر فرستنده LED ناموفق بود · {error} · فعال: {backend}","LED transmitter change was accepted, but active state is unconfirmed. No command was replayed.":"تغییر فرستنده LED پذیرفته شد، اما وضعیت فعال تأیید نشده است. فرمان دوباره ارسال نشد.","Device session changed; command was not replayed":"نشست دستگاه تغییر کرد؛ فرمان دوباره ارسال نشد","All panels":"همهٔ پنل‌ها","Read-only":"فقط خواندنی","Lock lost cells":"قفل خانه‌های بازنده","Export JSON":"خروجی JSON","Export CSV":"خروجی CSV","Apply assignments":"اعمال رنگ‌های تخصیص‌یافته","Cell":"خانه","Language & input":"زبان و ورودی","Preferences · This browser":"تنظیمات · این مرورگر","Close appearance":"بستن تنظیمات ظاهر","Apply preferences":"اعمال تنظیمات","Accent":"رنگ تأکیدی","System / default blue":"سیستم / آبی پیش‌فرض","System follows this browser’s light/dark setting.":"حالت سیستم از تنظیم روشن یا تیرهٔ این مرورگر پیروی می‌کند.","Actual shared Pealayer palette definitions.":"پالت‌های رنگ مشترک با پی‌لیر.","System uses blue in this standalone device UI.":"رنگ پیش‌فرض سیستم در این رابط دستگاه، آبی است.","Button text contrast is selected automatically.":"رنگ متن دکمه برای خوانایی، خودکار انتخاب می‌شود.","Silent mode ·":"حالت بی‌صدا ·","Use your configured device credentials.":"مشخصات ورود تنظیم‌شدهٔ دستگاه را وارد کنید.","Sign in":"ورود","Connecting to device…":"در حال اتصال به دستگاه…","Retry connection":"تلاش دوباره برای اتصال","Device access storage is unavailable. USB recovery is required.":"ذخیره‌سازی دسترسی دستگاه در دسترس نیست. بازیابی با USB لازم است.","Connection unavailable. Retry to read the device.":"اتصال در دسترس نیست. برای خواندن دستگاه دوباره تلاش کنید.","Sign in to this device.":"به این دستگاه وارد شوید.","First login: admin / pakarcontroller. Change these in Device access after signing in.":"ورود نخست: admin / pakarcontroller. پس از ورود، این مقادیر را در بخش دسترسی دستگاه تغییر دهید.","AtomicDeploy control surface":"پنل کنترل AtomicDeploy","WebSocket —":"وب‌سوکت —","WebSocket retrying":"تلاش دوباره برای وب‌سوکت","WebSocket live":"وب‌سوکت زنده","Reconnect WebSocket":"اتصال دوبارهٔ وب‌سوکت","Disconnect · use HTTP":"قطع وب‌سوکت · استفاده از HTTP","HTTP refresh · WS off":"تازه‌سازی HTTP · وب‌سوکت خاموش","HTTP status refresh continues. USB transport is managed by the desktop app.":"وضعیت از طریق HTTP تازه‌سازی می‌شود. اتصال USB در برنامهٔ دسکتاپ مدیریت می‌شود.","Panels ▾":"پنل‌ها ▾","Table board":"برد میز","Controller":"کنترلر","Table presentation":"نمایش میز","Game":"بازی","Cell selection":"انتخاب خانه‌ها","First wins":"اولین فشردن برنده است","Waiting for board capabilities…":"در انتظار قابلیت‌های برد…","Capabilities unavailable or stale. Connect to a compatible board.":"قابلیت‌ها در دسترس یا به‌روز نیستند. به برد سازگار متصل شوید.","Read-only: refresh to obtain a supported board/boot identity":"فقط خواندنی: برای دریافت هویت معتبر برد و راه‌اندازی، تازه‌سازی کنید","Top first · CCW · indices 0–479 · sampled RGB before brightness":"شروع از بالا · پادساعت‌گرد · شماره‌های ۰ تا ۴۷۹ · RGB نمونه‌برداری‌شده پیش از اعمال روشنایی","Click selects · Ctrl toggles · Shift extends · drag selects · Enter manages":"کلیک: انتخاب · Ctrl: تغییر انتخاب · Shift: گسترش · کشیدن: انتخاب محدوده · Enter: مدیریت","Find logical cells":"یافتن خانه‌های منطقی","80 logical cells":"۸۰ خانهٔ منطقی","Clear selection":"پاک‌کردن انتخاب","State":"وضعیت","Fade":"تغییر تدریجی","Immediate":"آنی","fade":"تغییر تدریجی","immediate":"آنی","pulse":"تپش","none":"بدون جلوه","rainbow":"رنگین‌کمان","fire":"آتش","Once":"یک بار","Loop":"تکرار پیوسته","Interval":"با فاصله","Buzzer":"بیزر","Play cue":"پخش ملودی","Available melody":"ملودی‌های موجود","Live TM1637 preview":"پیش‌نمایش زندهٔ TM1637","Last complete LCD frame":"آخرین تصویر کامل LCD","Display text":"متن نمایشگر","UART errors":"خطاهای UART","Online":"متصل","Offline":"قطع‌شده","Not responding":"بدون پاسخ","Waiting":"در انتظار","Offline ({code})":"قطع‌شده ({code})","Row {row}":"ردیف {row}","Select row {row}":"انتخاب ردیف {row}","{state} · {count} cells":"{state} · {count} خانه","Static":"ثابت","Effects":"جلوه‌ها","Rainbow test":"آزمایش رنگین‌کمان","LED driver: {driver}":"راه‌انداز LED: {driver}","Effects: animated output enabled explicitly.":"جلوه‌ها: خروجی متحرک فعال شده است.","Logical cell {id}":"خانهٔ منطقی {id}","Collapse or expand {panel}":"جمع یا باز کردن {panel}","Physical pixel · 1..480":"پیکسل فیزیکی · ۱ تا ۴۸۰","Physical pixel index from 1 to 480":"شمارهٔ پیکسل فیزیکی از ۱ تا ۴۸۰","Previous pixel":"پیکسل قبلی","Show white dot":"نمایش نقطهٔ سفید","Next pixel":"پیکسل بعدی","One physical pixel white, all others black. Explicitly selects effects mode; no animation. Enable LED output first.":"یک پیکسل فیزیکی سفید و بقیه خاموش می‌شوند. حالت جلوه‌ها بدون پویانمایی انتخاب می‌شود. ابتدا خروجی LED را فعال کنید.","RGB unavailable":"RGB در دسترس نیست","Close cell manager":"بستن مدیریت خانه‌ها","Choose rows or cells, then apply a state and transition to the selection. Ctrl toggles, Shift extends. Selection never changes LEDs.":"ردیف‌ها یا خانه‌ها را انتخاب کنید، سپس وضعیت و گذار را اعمال کنید. Ctrl انتخاب را تغییر می‌دهد و Shift آن را گسترش می‌دهد. انتخاب به‌تنهایی LEDها را تغییر نمی‌دهد.","Edit order · drag before/after a cell; IDs and wiring stay unchanged":"ویرایش ترتیب · خانه را به قبل یا بعد از خانه‌ای بکشید؛ شناسه‌ها و سیم‌کشی تغییر نمی‌کنند","Apply updates all selected cells together.":"اعمال، همهٔ خانه‌های انتخاب‌شده را با هم به‌روزرسانی می‌کند.","Apply to selection":"اعمال به انتخاب","Board display name":"نام نمایشی برد","Hostname":"نام میزبان","Living room table":"میز اتاق نشیمن","Application branding comes from the running firmware. Board names are saved across OTA. Only hostname changes restart Wi-Fi/AP; passwords are unchanged.":"هویت برنامه از میان‌افزار فعال دریافت می‌شود. نام برد پس از OTA حفظ می‌شود. فقط تغییر نام میزبان، وای‌فای و نقطهٔ دسترسی را راه‌اندازی مجدد می‌کند؛ گذرواژه‌ها تغییر نمی‌کنند.","Wi-Fi & Ethernet":"وای‌فای و اترنت","Scan for networks":"جست‌وجوی شبکه‌ها","Scan":"جست‌وجو","Enter SSID or choose a scanned network":"نام شبکه را وارد یا از نتیجهٔ جست‌وجو انتخاب کنید","Station password (re-enter on save)":"گذرواژهٔ ایستگاه","Replacement password · blank keeps stored":"گذرواژهٔ جایگزین · خالی یعنی حفظ مقدار ذخیره‌شده","Automatic IPv4 (DHCP)":"آدرس‌دهی خودکار IPv4 (DHCP)","IPv4 address":"نشانی IPv4","Gateway":"دروازهٔ شبکه","Subnet mask":"ماسک زیرشبکه","Primary DNS":"DNS اصلی","Secondary DNS":"DNS دوم","Leave the password blank to keep it for the unchanged SSID. Explicitly choose Open for a passwordless network. Addressing controls appear only when reported by this firmware.":"برای حفظ گذرواژهٔ همان شبکه، این قسمت را خالی بگذارید. برای شبکهٔ بدون گذرواژه، گزینهٔ شبکهٔ باز را انتخاب کنید. تنظیمات نشانی فقط در صورت پشتیبانی میان‌افزار نمایش داده می‌شوند.","Save & verify Wi-Fi":"ذخیره و تأیید وای‌فای","Cloud links":"ارتباطات ابری","Replace password":"جایگزینی گذرواژه","Clear stored password":"پاک‌کردن گذرواژهٔ ذخیره‌شده","Replace token":"جایگزینی توکن","Clear stored token":"پاک‌کردن توکن ذخیره‌شده","Clear token":"پاک‌کردن توکن","Save & verify cloud":"ذخیره و تأیید ارتباطات ابری","Reconnect cloud links":"اتصال دوبارهٔ ارتباطات ابری","TLS validates endpoints against ISRG Root X1. MQTT commands use //command; status and availability are retained.":"TLS گواهی مقصد را با ISRG Root X1 اعتبارسنجی می‌کند. مسیر فرمان MQTT برابر //command است؛ وضعیت و دسترس‌پذیری نگه‌داری می‌شوند.","Require authentication":"نیاز به احراز هویت","Current password":"گذرواژهٔ کنونی","New username":"نام کاربری جدید","New password":"گذرواژهٔ جدید","Confirm password":"تأیید گذرواژه","Save device access":"ذخیرهٔ دسترسی دستگاه","Saved on the ESP and retained across firmware OTA. Authentication is off unless enabled here. Changes apply to API, WebSocket and Telnet and reconnect all clients. It does not change the Wi-Fi password.":"روی ESP ذخیره و پس از OTA حفظ می‌شود. احراز هویت تا زمانی که اینجا فعال نشود خاموش است. تغییرات روی API، وب‌سوکت و تلنت اعمال می‌شوند و کلاینت‌ها دوباره متصل می‌شوند. گذرواژهٔ وای‌فای تغییر نمی‌کند.","Wired Ethernet":"اترنت سیمی","Ethernet unavailable":"اترنت در دسترس نیست","Enable Ethernet":"فعال‌کردن اترنت","Automatic Ethernet addressing (DHCP)":"آدرس‌دهی خودکار اترنت (DHCP)","Advertise link identity (LLDP)":"اعلام هویت پیوند (LLDP)","Advanced: receive LLDP neighbors (promiscuous Ethernet)":"پیشرفته: دریافت همسایه‌های LLDP (اترنت فراگیر)","Read / discard Ethernet draft":"خواندن و لغو پیش‌نویس اترنت","Save Ethernet and verify":"ذخیره و تأیید اترنت","IPv4 ready · {ip}":"IPv4 آماده · {ip}","Link up · waiting for IPv4":"پیوند برقرار · در انتظار IPv4","Link down":"پیوند قطع است","Firmware notifications · ntfy":"اعلان‌های میان‌افزار · ntfy","Send firmware-update notifications (opt in)":"ارسال اعلان‌های به‌روزرسانی میان‌افزار (اختیاری)","HTTPS server":"سرور HTTPS","Private topic":"موضوع خصوصی","Optional bearer token":"توکن دسترسی اختیاری","Disabled by default. Major stages and results only; flashing never waits for delivery. No ntfy message can trigger an update.":"پیش‌فرض خاموش است. فقط مراحل مهم و نتیجه‌ها ارسال می‌شوند؛ فلش‌کردن منتظر تحویل اعلان نمی‌ماند. هیچ پیام ntfy نمی‌تواند به‌روزرسانی را شروع کند.","Save notifications and verify":"ذخیره و تأیید اعلان‌ها","Onboard semantic status RGB":"چراغ RGB وضعیت روی برد","Separate status pixel · board-controlled animation.":"پیکسل وضعیت مستقل · پویانمایی با کنترل برد.","Onboard pixel GPIO":"پایهٔ پیکسل روی برد","Unassigned":"تخصیص‌نیافته","GPIO48 · common DevKit":"GPIO48 · DevKit رایج","GPIO38 · alternate DevKit":"GPIO38 · DevKit جایگزین","Confirm this is the onboard addressable RGB pin":"تأیید می‌کنم این پایهٔ RGB آدرس‌پذیر روی برد است","Enable semantic status output":"فعال‌کردن خروجی وضعیت معنایی","Status RGB brightness":"روشنایی RGB وضعیت","Read / discard draft":"خواندن و لغو پیش‌نویس","Save onboard RGB":"ذخیرهٔ RGB روی برد","Read settings before editing. Preview shows reported RGB.":"پیش از ویرایش، تنظیمات را بخوانید. پیش‌نمایش، RGB گزارش‌شده را نشان می‌دهد.","Verified remote update":"به‌روزرسانی تأییدشدهٔ راه دور","Manifest HTTPS URL":"نشانی HTTPS مانیفست","Manifest SHA-256":"SHA-256 مانیفست","Endpoint bearer (optional)":"توکن دسترسی مقصد (اختیاری)","Both boards":"هر دو برد","ESP (same-version repair)":"ESP (ترمیم همان نسخه)","AVR (same-version repair)":"AVR (ترمیم همان نسخه)","Download & verify only":"فقط بارگیری و اعتبارسنجی","Refresh job":"تازه‌سازی عملیات","Apply verified job…":"اعمال عملیات تأییدشده…","Cancel job":"لغو عملیات","Exact SHA-256, profile and version checks. Applying preserves and verifies AVR EEPROM. Two-board updates are not atomic across power loss; uncertain outcomes keep Prog armed. No automatic retry.":"SHA-256، پروفایل و نسخه دقیقاً بررسی می‌شوند. EEPROM برد AVR حفظ و تأیید می‌شود. قطع برق ممکن است به‌روزرسانی دو برد را ناقص کند؛ در نتیجهٔ نامعلوم، Prog فعال می‌ماند. تلاش دوبارهٔ خودکار انجام نمی‌شود.","No job observed":"عملیاتی دریافت نشده است","Device & client telemetry":"دورسنجی دستگاه و کلاینت‌ها","Read complete snapshot":"خواندن وضعیت کامل","Firmware vault":"مخزن میان‌افزار","Store AVR image":"ذخیرهٔ تصویر AVR","Back up AVR":"پشتیبان‌گیری از AVR","Program + verify":"برنامه‌ریزی و تأیید","Flash backup":"پشتیبان فلش","ESP self-OTA":"OTA خود ESP","Target FPS":"نرخ فریم هدف","Buffered frames":"فریم‌های بافرشده","Streaming target FPS":"نرخ فریم هدف پخش","Maximum buffered stream frames":"حداکثر فریم‌های بافرشدهٔ پخش","Browser policy · 0 is uncapped · skip stale frames under backpressure. Sends are not measured LED refresh.":"سیاست مرورگر · صفر بدون سقف است · هنگام فشار بافر، فریم‌های قدیمی رد می‌شوند. ارسال‌ها معیار نرخ تازه‌سازی LED نیستند.","No browser stream started":"پخش مرورگر آغاز نشده است","Browser sends":"ارسال‌های مرورگر","Stopped":"متوقف شد","Stopped: peer, capability or connection changed":"متوقف شد: دستگاه، قابلیت یا اتصال تغییر کرد","Stopped: socket buffering is unknown":"متوقف شد: وضعیت بافر سوکت نامشخص است","Stopped: partial frame outcome unknown; not replayed":"متوقف شد: نتیجهٔ فریم ناقص نامشخص است؛ دوباره ارسال نمی‌شود","Stopped: send failed or policy invalid; outcome unknown, not replayed":"متوقف شد: ارسال ناموفق یا سیاست نامعتبر است؛ نتیجه نامشخص است و دوباره ارسال نمی‌شود","Stopped: page owner disposed":"متوقف شد: مالک صفحه پایان یافت","Choose 0–240 FPS and 1–16 buffered frames. 0 means uncapped.":"نرخ فریم ۰ تا ۲۴۰ و بافر ۱ تا ۱۶ فریم را انتخاب کنید. صفر بدون سقف است.","Connect to an acknowledged streaming board first":"ابتدا به بردی با قابلیت پخش تأییدشده متصل شوید","Stream owner disposed":"مالک پخش پایان یافته است","{state} · {frames} frames · rate pending · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · نرخ در انتظار · {skipped} ردشده · {bytes} بایت در صف سوکت","{state} · {frames} frames · {rate} FPS average · {skipped} skipped · socket queued {bytes} bytes":"{state} · {frames} فریم · میانگین {rate} فریم بر ثانیه · {skipped} ردشده · {bytes} بایت در صف سوکت","Dismiss {title}":"بستن {title}","{count} selected":"{count} انتخاب‌شده","This board has not acknowledged atomic cell batches. No per-cell fallback was sent.":"این برد فرمان گروهی یکپارچهٔ خانه‌ها را تأیید نکرده است؛ فرمان‌های جداگانه ارسال نشدند.","Sending one board command for {total} cells; sent writes cannot be cancelled.":"یک فرمان برای {total} خانه به برد ارسال می‌شود؛ فرمان ارسال‌شده قابل لغو نیست.","Device confirmed colors for {total} cells":"رنگ {total} خانه توسط دستگاه تأیید شد","Status LED settings saved on device":"تنظیمات چراغ وضعیت روی دستگاه ذخیره شد","Outcome unknown. The single command may have applied; it was not retried. Refresh and inspect.":"نتیجه نامشخص است. ممکن است فرمان اعمال شده باشد؛ دوباره ارسال نشد. تازه‌سازی و بررسی کنید.","No command was sent. Refresh before a new action.":"فرمانی ارسال نشد. پیش از اقدام بعدی تازه‌سازی کنید.","Pixel groups":"گروه‌های پیکسل","Whole group":"تمام گروه","Customize group":"تعداد سفارشی گروه","Physical group":"گروه فیزیکی","Physical pixels":"پیکسل‌های فیزیکی","Use default":"استفاده از پیش‌فرض","All groups use the default pixel count":"تمام گروه‌ها از تعداد پیکسل پیش‌فرض استفاده می‌کنند","Decrease pixel count":"کاهش تعداد پیکسل","Increase pixel count":"افزایش تعداد پیکسل","Discard correction edits?":"ویرایش‌های اصلاح پیکسل کنار گذاشته شوند؟","Discard pixel group edits?":"ویرایش‌های گروه پیکسل کنار گذاشته شوند؟","Enter a pixel count from 1 to 255":"تعداد پیکسل را از ۱ تا ۲۵۵ وارد کنید","Reload keeps the saved board configuration and discards only these edits.":"بارگذاری مجدد، تنظیمات ذخیره‌شدهٔ برد را نگه می‌دارد و فقط این ویرایش‌ها را کنار می‌گذارد.","Correction confirmation is unavailable. Edits were kept.":"پنجرهٔ تأیید اصلاحات در دسترس نیست. ویرایش‌ها حفظ شدند.","Device session changed. Edits were kept.":"نشست دستگاه تغییر کرد. ویرایش‌ها حفظ شدند.","Decrease pixel":"کاهش شمارهٔ پیکسل","Increase pixel":"افزایش شمارهٔ پیکسل","Decrease offset":"کاهش جابه‌جایی","Increase offset":"افزایش جابه‌جایی","Complete the pixel address":"نشانی پیکسل را کامل کنید","Complete prior rules to locate source":"برای تعیین مبدأ، قواعد قبلی را کامل کنید","Black from prior rule · no logical source":"خاموش‌شده با قاعدهٔ قبلی · بدون مبدأ منطقی","Outside assigned groups":"خارج از گروه‌های تخصیص‌یافته","Logical address not current":"نشانی منطقی به‌روز نیست","Logical address unavailable":"نشانی منطقی در دسترس نیست","Logical cell unavailable":"خانهٔ منطقی در دسترس نیست","Wire":"نشانی خروجی","Raw":"خام","Use selected cursor":"استفاده از مکان‌نمای انتخاب‌شده","Use selected cursor ({pixel})":"استفاده از مکان‌نمای انتخاب‌شده ({pixel})","Wire map":"نگاشت مسیر پیکسل","Applied map":"نگاشت اعمال‌شده","Draft map":"نگاشت پیش‌نویس","Draft pixel mapping":"نگاشت پیش‌نویس پیکسل‌ها","Find physical pixel in wire map":"یافتن پیکسل فیزیکی در نگاشت مسیر","Local preview · not applied":"پیش‌نمایش محلی · اعمال نشده","Complete invalid values to preview the draft":"برای پیش‌نمایش پیش‌نویس، مقدارهای نامعتبر را کامل کنید","Applied mapping unavailable":"نگاشت اعمال‌شده در دسترس نیست","No matching pixels":"پیکسل مطابقی پیدا نشد","Advanced probe":"تنظیمات پیشرفتهٔ آزمون پیکسل","Probe effect":"جلوهٔ آزمون پیکسل","Composition":"ترکیب با صحنه","Solid":"ثابت","Flash":"چشمک‌زن","Breathing":"تنفسی","Solo":"مستقل","Overlay":"روی صحنه","Period · ms":"دوره · میلی‌ثانیه","On time · %":"زمان روشن · درصد","Minimum brightness":"کمینهٔ روشنایی","Maximum brightness":"بیشینهٔ روشنایی","Complete diagnostic timing and brightness":"زمان‌بندی و روشنایی آزمون را کامل کنید","Invalid diagnostic options":"تنظیمات آزمون نامعتبر است","Invalid diagnostic timing or brightness":"زمان‌بندی یا روشنایی آزمون نامعتبر است","Minimum brightness exceeds maximum":"کمینهٔ روشنایی از بیشینه بیشتر است","Diagnostic effects were not acknowledged":"پشتیبانی جلوه‌های آزمون تأیید نشده است","Diagnostic overlay was not acknowledged":"پشتیبانی آزمون روی صحنه تأیید نشده است","Board reports probe mask off":"برد خاموشی محدودهٔ آزمون را گزارش می‌کند","Diagnostic frame publication is not confirmed":"انتشار فریم آزمون تأیید نشده است","Rule preview":"پیش‌نمایش قاعده","Send black to this address. Later pixels keep their positions.":"این نشانی خاموش می‌شود و پیکسل‌های بعدی در جای خود می‌مانند.","Skip this value. Following values move one address earlier; the last address is black.":"این مقدار حذف می‌شود؛ مقدارهای بعدی یک نشانی جلو می‌آیند و نشانی آخر خاموش می‌شود.","Move this pixel and all following values. Positive moves later; negative moves earlier. Empty addresses are black.":"این پیکسل و مقدارهای پس از آن جابه‌جا می‌شوند؛ مثبت به عقب و منفی به جلو. نشانی‌های خالی خاموش می‌شوند.","Fire direction":"جهت شعله","0° · Up":"۰° · بالا","90° · Right":"۹۰° · راست","180° · Down":"۱۸۰° · پایین","270° · Left":"۲۷۰° · چپ","Static groups: matching pixels. Rainbow and Fire animate when selected.":"گروه‌های ثابت: پیکسل‌های هم‌رنگ. رنگین‌کمان و آتش با انتخاب متحرک می‌شوند."}; // END SHARED PERSIAN DICTIONARY // Actual RayanLamp i18n.tsx normalize/detect/t/digits/controller contracts (MIT). // Keep Pakar's primary-language policy, explicit direction and peer identity. @@ -913,6 +920,10 @@ function createCommandTransport({snapshot,socket,http,valid,fallback=true,now=()=>Date.now(),random=()=>crypto.getRandomValues(new Uint8Array(16))}){ const names={'/api/cells':'cells','/api/cell':'cell','/api/game/press':'game-press','/api/game/reset':'reset','/api/game/settings':'game-settings','/api/effect':'effect','/api/mode':'mode','/api/brightness':'brightness','/api/led/output':'led-output','/api/cells/lock':'cell-lock','/api/cells/assignments':'cell-assignments','/api/led/diagnostic':'pixel-diagnostic','/api/led/presentation':'state-presentation','/api/led/preview':'color-preview'}; const pending=new Map(); + names['/api/pixels/corrections/preview']='pixel-corrections-preview'; + names['/api/presentation']='presentation'; + names['/api/audio']='audio';names['/api/buzzer']='buzzer';names['/api/outputs']='outputs'; + for(const action of ['configure','control','input'])names['/api/minigame/'+action]='minigame-'+action; function receive(reply){const p=pending.get(reply?.request_id);if(!p)return false;if(reply.type!=='command-result'||reply.board_id!==p.board||reply.boot_id!==p.boot)return false;p.reply=reply;p.resolve(reply);return true} const integer=(value,key,min=0,max=Number.MAX_SAFE_INTEGER)=>{ if(typeof value==='string'&&!/^(?:0|[1-9]\d*)$/.test(value))throw Error('Invalid '+key); @@ -928,6 +939,41 @@ }; function typedValues(path,values){ const result={...values}; + if(path==='/api/minigame/configure'){ + const settings=structured(result.settings,'minigame settings'); + const limits={limit_ms:[0,86400000],warning_ms:[0,86400000],urgent_ms:[0,86400000],penalty_ms:[0,86400000],window_ms:[100,60000],show_ms:[100,10000],gap_ms:[50,5000],feedback_ms:[50,5000],intro_ms:[100,10000],preview_ms:[0,10000],mismatch_ms:[600,10000],rounds:[1,32],sequence_length:[1,32],palette_size:[2,9],fixed_target:[0,9]}; + for(const key of Object.keys(settings))if(!Object.hasOwn(limits,key)&&!['mode','hard','intro_enabled','color_arpeggio','toggle_kind','outputs'].includes(key))throw Error('Unknown minigame setting'); + for(const [key,[min,max]] of Object.entries(limits))if(key in settings)settings[key]=integer(settings[key],key,min,max); + for(const key of ['hard','intro_enabled','color_arpeggio'])if(key in settings)settings[key]=boolean(settings[key],key); + if('mode' in settings&&!snapshot().minigame?.available_modes?.some(mode=>mode.id===settings.mode))throw Error('Unavailable minigame'); + if('toggle_kind' in settings&&!['toggle','latch','press'].includes(settings.toggle_kind))throw Error('Invalid toggle behavior'); + if('outputs' in settings){ + const rows=structured(settings.outputs,'minigame outputs',true); + if(rows.length!==8)throw Error('Expected eight output mappings'); + settings.outputs=rows.map(row=>{ + const sources=['disabled','active','success','failure','held','latched','winner','lost']; + if(!sources.includes(row?.source))throw Error('Invalid output source'); + const key=integer(row.key,'output key',0,9); + if((sources.indexOf(row.source)<4)!==(key===0))throw Error('Invalid output key'); + return {source:row.source,key}; + }); + } + result.settings=settings; + } + if(path==='/api/minigame/control'&&!['start','pause','resume','reset','stop'].includes(result.action))throw Error('Invalid minigame action'); + if(path==='/api/minigame/input'){ + result.key=integer(result.key,'key',1,9); + if(!['tap','down','up'].includes(result.action))throw Error('Invalid minigame input'); + } + if(path==='/api/pixels/corrections/preview')result.configuration=structured(result.configuration,'correction preview'); + if(path==='/api/presentation')return structured(result.presentation,'host presentation'); + if(path==='/api/audio'&&(typeof result.cue!=='string'||!/^[a-z0-9][a-z0-9-]{0,39}$/.test(result.cue)))throw Error('Invalid melody'); + if(path==='/api/buzzer'){ + result.frequency=integer(result.frequency,'frequency',0,20000); + if(result.frequency>0&&result.frequency<20)throw Error('Invalid frequency'); + result.duration_ms=integer(result.duration_ms,'duration_ms',1,65535); + } + if(path==='/api/outputs')result.mask=integer(result.mask,'mask',0,255); for(const key of ['id','key','index','count','rgb','group_layout_revision'])if(key in result)result[key]=integer(result[key],key); if(path==='/api/brightness')result.value=integer(result.value,'brightness',0,255); for(const key of ['enabled','off'])if(key in result)result[key]=boolean(result[key],key); @@ -962,6 +1008,10 @@ } async function send(path,values){ const observed=snapshot();if(!names[path]||!valid()||!Number.isSafeInteger(observed.command_clock_ms)||observed.command_clock_ms<0||observed.command_clock_ms>Number.MAX_SAFE_INTEGER-8000)return null; + if(path.startsWith('/api/minigame/')&&observed.minigame?.ready!==true)return null; + const miniRevision=observed.minigame?.revision??0; + const avr=['/api/audio','/api/buzzer','/api/outputs'].includes(path); + if(avr&&(!observed.avr_online||!observed.capabilities?.acknowledged_features?.includes('avr-command-ack')))return null; // Capture immutable identity values, not a mutable snapshot object. const s={board_id:observed.board_id,boot_id:observed.boot_id,command_clock_ms:observed.command_clock_ms}; const request_id=Array.from(random(),x=>x.toString(16).padStart(2,'0')).join(''); @@ -988,8 +1038,17 @@ } if(!same()||reply?.type!=='command-result'||reply.request_id!==request_id||reply.board_id!==s.board_id||reply.boot_id!==s.boot_id)throw Error('Uncorrelated command receipt; inspect device state'); if(reply.accepted!==true)throw Error(reply.error||'Command rejected'); + if(avr&&reply.avr_acknowledged!==true)throw Error('AVR did not acknowledge the command'); if((path==='/api/game/reset'||path==='/api/game/press')&&reply.avr_acknowledged!==true)throw Error('AVR did not acknowledge the command'); if(reply.status?.board_id!==s.board_id||reply.status?.boot_id!==s.boot_id)throw Error('Command readback missing'); + if(path.startsWith('/api/minigame/')){ + const actual=reply.status.minigame; + if(!actual||!Number.isSafeInteger(actual.revision)||actual.revisionread[i]?.source===rule.source&&read[i]?.key===rule.key):read===value; + if(!matches)throw Error('Minigame settings readback differs'); + } + } return reply; }finally{clearTimeout(timer);clearTimeout(deadlineTimer);httpController?.abort();pending.delete(request_id)} } @@ -1003,12 +1062,14 @@ (() => { 'use strict'; const paths = { + folder:'M3 6V4h7l2 3h9v13H3zM3 8h18', controls:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z', table:'M3 4h18v16H3zM3 9h18M3 14h18M9 4v16M15 4v16', mapping:'M3 4h6v6H3zM13 4h8v6h-8zM3 14h10v6H3zM17 14h4v6h-4z', diagnostics:'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15m5.3 12.8L21 21M7 10.5h7m-3.5-3.5v7', search:'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15m5.3 12.8L21 21', effects:'m12 3 2.2 6.8L21 12l-6.8 2.2L12 21l-2.2-6.8L3 12l6.8-2.2z', + fire:'M13 2c1 5-4 6-3 10-2-1-3-3-3-4-4 4-4 9-1 12 4 4 12 1 12-5 0-4-2-7-5-13zM12 14c-3 2-3 6 0 7 3-1 3-4 0-7z', hardware:'M6 6h12v12H6zM9 2v4m6-4v4M9 18v4m6-4v4M2 9h4m-4 6h4m12-6h4m-4 6h4', settings:'M9 3h6l.7 3 2.5 1.5 3-.9 3 5.2-2.3 2.1v2.9l2.3 2.1-3 5.2-3-.9-2.5 1.5-.7 3H9l-.7-3-2.5-1.5-3 .9-3-5.2 2.3-2.1v-2.9L-.2 9.8l3-5.2 3 .9L8.3 4z', sliders:'M4 5h16M4 12h16M4 19h16M8 3v4m8 3v4M10 17v4', @@ -1122,7 +1183,7 @@ openMenu?.();const panel=node('div','web-menu');panel.setAttribute('role','menu');panel.setAttribute('aria-label',label);const previous=document.activeElement; const close=(restore=true)=>{panel.remove();document.removeEventListener('pointerdown',outside,true);document.removeEventListener('keydown',keys,true);window.removeEventListener('resize',resize);if(restore&&previous?.isConnected)previous.focus();anchor?.setAttribute?.('aria-expanded','false');if(openMenu===close)openMenu=null;}; const run=item=>{close();return item.action?.();}; - for(const item of items){if(item.separator){panel.append(node('div','web-menu-separator'));continue;}if(item.heading){panel.append(node('div','web-menu-label',item.heading));continue;}const b=button(item.label,()=>run(item),{className:'web-menu-item',disabled:item.disabled});b.replaceChildren(icon(item.checked?'check':item.icon||'controls'),node('span','',item.label),node('span','shortcut',item.shortcut||''));b.setAttribute('role',typeof item.checked==='boolean'?'menuitemcheckbox':'menuitem');if(typeof item.checked==='boolean')b.setAttribute('aria-checked',String(item.checked));if(item.danger)b.dataset.danger='true';panel.append(b);} + for(const item of items){if(item.separator){panel.append(node('div','web-menu-separator'));continue;}if(item.heading){panel.append(node('div','web-menu-label',item.heading));continue;}const b=button(item.label,()=>run(item),{className:'web-menu-item',disabled:item.disabled});const copy=node('span',item.description?'menu-copy':'',item.label);if(item.description)copy.append(node('small','',item.description));b.replaceChildren(icon(item.icon||'controls'),copy,node('span','shortcut',typeof item.checked==='boolean'?(item.checked?'✓':''):item.shortcut||''));b.setAttribute('role',typeof item.checked==='boolean'?'menuitemcheckbox':'menuitem');if(typeof item.checked==='boolean')b.setAttribute('aria-checked',String(item.checked));if(item.danger)b.dataset.danger='true';panel.append(b);} const outside=e=>{if(!panel.contains(e.target)&&!anchor?.contains?.(e.target))close(false);}; const keys=e=>{const list=[...panel.querySelectorAll('button:not(:disabled)')],i=list.indexOf(document.activeElement);if(e.key==='Escape'){e.preventDefault();close();}else if(['ArrowDown','ArrowUp','Home','End'].includes(e.key)){e.preventDefault();const next=e.key==='Home'?0:e.key==='End'?list.length-1:(i+(e.key==='ArrowDown'?1:-1)+list.length)%list.length;list[next]?.focus();}else if(e.key==='Tab')close(false);}; const resize=()=>close(false);document.body.append(panel);const r=anchor?.getBoundingClientRect?.()||{left:16,bottom:16};const pr=panel.getBoundingClientRect();panel.style.left=Math.max(8,Math.min(r.left,innerWidth-pr.width-8))+'px';panel.style.top=Math.max(8,Math.min(r.bottom+6,innerHeight-pr.height-8))+'px';anchor?.setAttribute?.('aria-expanded','true');document.addEventListener('pointerdown',outside,true);document.addEventListener('keydown',keys,true);window.addEventListener('resize',resize);panel.querySelector('button:not(:disabled)')?.focus();openMenu=close;return {element:panel,close}; @@ -1224,6 +1285,99 @@ root.WebTableLayout={position,neighbor,zoomAt,attach}; })(globalThis); +// Adapted from the fully read mobile/lib/pixel_correction_preview.dart. +// This controller owns only an explicit RAM preview lease, never a transport, +// socket, renderer, saved settings or automatic startup. The canonical editor +// must inject its existing pinned send/read functions and current owner key. +(function(root){ + 'use strict'; + function createPixelCorrectionPreview({owner,allowed,configuration,pixels,project,send,read,onChange=()=>{},now=()=>Date.now(),setTimer=setTimeout,clearTimer=clearTimeout}){ + const ttl=10000,releases=new Map();let disposed=false,generation=0,captured=null,queued=false,debounce=null,renew=null,expiry=null; + const state={enabled:false,pending:false,lease:null,observed:null,error:null}; + const snapshot=()=>({...state}); + const notify=()=>{if(!disposed)try{onChange(snapshot());}catch{/* Rendering cannot strand lease cleanup. */}}; + const same=identity=>!!identity&&owner()?.key===identity.key; + const active=epoch=>!disposed&&state.enabled&&generation===epoch&&allowed()&&same(captured); + function cancelTimers(){for(const id of [debounce,renew,expiry])if(id!==null)clearTimer(id);debounce=renew=expiry=null;} + function canonical(){ + const value=configuration(),count=pixels(); + if(!value||!Number.isInteger(value.revision)||value.revision<0||value.revision>0xffffffff||typeof value.enabled!=='boolean'||!Array.isArray(value.rules))throw Error('Invalid preview configuration'); + const rules=value.rules.map(r=>({kind:r.kind,pixel:r.pixel,offset:r.offset??0,enabled:r.enabled??true})); + // Reuse the canonical correction projection/validation, not another engine. + const sources=project(count,value.enabled,rules); + if(!Number.isInteger(count)||count<1||count>480||!Array.isArray(sources)||sources.length!==count||sources.some(n=>!Number.isInteger(n)||n< -1||n>=count))throw Error('Invalid correction projection'); + return {value:{revision:value.revision,enabled:value.enabled,rules},sources}; + } + async function release(identity,lease,revision){ + if(!same(identity)||!Number.isInteger(lease)||lease<1||lease>0xffffffff)return false; + const key=identity.key+':'+lease+':'+revision;if(releases.has(key))return releases.get(key); + const operation=(async()=>{try{ + const reply=await send({clear:true,lease_id:lease,revision},identity); + return same(identity)&&!reply?.error&&reply?.revision===revision&&reply?.preview?.active===false&&reply.preview.lease_id===0&&reply.preview.base_revision===revision&&reply.preview.remaining_ms===0; + }catch{return false;}})(); + releases.set(key,operation);if(releases.size>32)releases.delete(releases.keys().next().value);return operation; + } + async function stop(){ + const lease=state.lease,revision=state.observed?.revision,identity=captured,inFlight=state.pending,epoch=++generation; + state.enabled=false;state.lease=null;state.observed=null;queued=false;cancelTimers(); + if(lease!==null)state.pending=true;notify(); + if(lease!==null){const confirmed=await release(identity,lease,revision); + if(!inFlight)state.pending=false; + if(!confirmed&&generation===epoch&&same(identity))state.error='Preview release unconfirmed. The board lease will expire; no command was replayed.'; + notify();return confirmed; + }return true; + } + async function transmit(){ + if(disposed||!state.enabled||state.pending)return; + if(!allowed()||!same(captured)){await stop();return;} + if(renew!==null)clearTimer(renew);renew=null; + const epoch=generation,identity=captured,issued=state.lease,started=now();let received=issued,revision=state.observed?.revision; + state.pending=true;state.error=null;queued=false;notify(); + try{ + const wanted=canonical(); + if(issued!==null&&wanted.value.revision!==revision)throw Error('Saved corrections changed. Preview stopped without replay.'); + revision=wanted.value.revision; + const reply=await send({...wanted.value,ttl_ms:ttl,...(issued===null?{}:{lease_id:issued})},identity),receipt=reply?.preview; + if(reply?.error)throw Error(String(reply.error)); + if(receipt?.active!==true||!Number.isInteger(receipt.lease_id)||receipt.lease_id<1||receipt.lease_id>0xffffffff)throw Error('Preview lease receipt missing'); + if(issued!==null&&receipt.lease_id!==issued)throw Error('Preview owner changed. No foreign preview was modified.'); + received=receipt.lease_id; + if(!active(epoch)){await release(identity,received,revision);return;} + const result=await read(identity),preview=result?.preview,actual=result?.active_sources; + if(!active(epoch)){await release(identity,received,revision);return;} + if(result?.revision!==revision||preview?.active!==true||preview.lease_id!==received||preview.base_revision!==revision|| + !Number.isInteger(preview.remaining_ms)||preview.remaining_ms<1||preview.remaining_ms>ttl|| + !Array.isArray(actual)||actual.length!==wanted.sources.length||actual.some((n,i)=>n!==wanted.sources[i]))throw Error('Preview readback differs. No preview was confirmed.'); + const remaining=Math.min(preview.remaining_ms,ttl-Math.max(0,now()-started)); + if(remaining<500)throw Error('Preview confirmation arrived too late. No preview was confirmed.'); + state.lease=received;state.observed={revision,lease_id:received,remaining_ms:remaining}; + if(expiry!==null)clearTimer(expiry); + expiry=setTimer(()=>{state.error='Preview connection expired. No changes were saved.';void stop();},remaining); + renew=setTimer(()=>{renew=null;if(state.pending)queued=true;else void transmit();},Math.floor(remaining/2)); + }catch(error){ + if(received!==null)await release(identity,received,revision); + if(!disposed&&generation===epoch){state.enabled=false;state.lease=null;state.observed=null;state.error=error.message;cancelTimers();} + }finally{ + state.pending=false;notify(); + if(queued&&active(epoch)){queued=false;edited();} + } + } + function edited(){ + if(disposed||!state.enabled)return;if(debounce!==null)clearTimer(debounce); + debounce=setTimer(()=>{debounce=null;if(state.pending)queued=true;else void transmit();},280); + } + async function start(){ + if(disposed||state.enabled||state.pending||!allowed())return false; + const identity=owner();if(!identity||typeof identity.key!=='string'||!identity.key.length||identity.key.length>512)return false; + captured=Object.freeze({...identity});state.enabled=true;state.lease=null;state.observed=null;state.error=null;generation++; + await transmit();return state.enabled&&state.observed!==null; + } + function committed(){state.enabled=false;state.lease=null;state.observed=null;state.error=null;generation++;queued=false;cancelTimers();notify();} + return {start,edited,stop,committed,snapshot,observe(){if(state.enabled&&(!allowed()||!same(captured)))void stop();},dispose(){const cleanup=stop();disposed=true;return cleanup;}}; + } + root.createPixelCorrectionPreview=createPixelCorrectionPreview; +})(typeof window==='undefined'?globalThis:window); + // Physical spans are shared by the editor, diagnostics and captured RGB preview. // The bounded stepper/local draft pattern is adapted from the reviewed Pealayer // NumericValueControl; writes remain explicit, revision-fenced board operations. @@ -1285,15 +1439,20 @@ const key=owner,record=model;if(!ready()||reloadPrompt)return;requireCurrent(key); if(record.dirty){ if(!root.WebUI?.dialog)throw Error('Group confirmation is unavailable. Edits were kept.'); - const approved=await new Promise(resolve=>{ + await new Promise(resolve=>{ const prompt=root.WebUI.dialog({title:translate('Discard pixel group edits?'),closeLabel:translate('Close'), description:translate('Reload keeps the saved board configuration and discards only these edits.'), actions:[{label:translate('Cancel'),icon:'close',autofocus:true}, - {label:translate('Reload'),icon:'refresh',variant:'primary',action:()=>resolve(true)}]}); + {label:translate('Reload'),icon:'refresh',variant:'primary',action:async()=>{ + if(reloadPrompt!==prompt||!current(key)||model!==record||!ready())return false; + // Retain modal focus during the read; close only after the + // originating Reload control is enabled again. + await load();resolve(true); + }}]}); reloadPrompt=prompt; prompt.element.addEventListener('close',()=>{if(reloadPrompt===prompt)reloadPrompt=null;resolve(false);},{once:true}); }); - if(!approved)return; + return; } requireCurrent(key);if(model!==record)throw Error('Device session changed. Edits were kept.'); await load(); @@ -1342,6 +1501,78 @@ root.PixelGroups={path,defaults,parse,fromState,groupAt,probe,frameLayout,create}; })(typeof window==='undefined'?globalThis:window); +// Canonical Web file workspace. Adapted Pealayer ServerFilePicker's bounded +// listing, filter, explicit actions and stale-request suppression; no media paths. +(function(root){ + 'use strict'; + const hex=bytes=>Array.from(bytes,n=>n.toString(16).padStart(2,'0')).join(''); + const unhex=value=>{if(typeof value!=='string'||value.length%2||!/^[0-9a-f]*$/.test(value))throw Error('Invalid file bytes');return Uint8Array.from(value.match(/../g)||[],n=>parseInt(n,16));}; + // SHA-256 also works on an ESP's plain-HTTP origin (SubtleCrypto need not). + // No passwords/signatures are handled here; this is transfer byte integrity. + function sha256(bytes){ + const k=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; + const h=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; + const data=new Uint8Array(Math.ceil((bytes.length+9)/64)*64);data.set(bytes);data[bytes.length]=128; + const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(bytes.length/0x20000000));view.setUint32(data.length-4,bytes.length*8); + const r=(n,s)=>(n>>>s)|(n<<(32-s)),w=new Uint32Array(64); + for(let offset=0;offset>>3))+w[i-7]+(r(b,17)^r(b,19)^(b>>>10));} + let [a,b,c,d,e,f,g,z]=h; + for(let i=0;i<64;i++){const t=(z+(r(e,6)^r(e,11)^r(e,25))+((e&f)^(~e&g))+k[i]+w[i])|0,u=((r(a,2)^r(a,13)^r(a,22))+((a&b)^(a&c)^(b&c)))|0;z=g;g=f;f=e;e=(d+t)|0;d=c;c=b;b=a;a=(t+u)|0;} + [a,b,c,d,e,f,g,z].forEach((n,i)=>h[i]=(h[i]+n)|0); + } + return h.map(n=>(n>>>0).toString(16).padStart(8,'0')).join(''); + } + const validName=name=>typeof name==='string'&&/^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$/.test(name)&&!name.includes('..'); + // PAX mtime retains original millisecond precision, without altering payload. + function archive(entry,bytes){ + if(!validName(entry.name)||bytes.length!==entry.size||!Number.isSafeInteger(entry.modified_ms)||entry.modified_ms<0||sha256(bytes)!==entry.sha256)throw Error('Invalid archive metadata or bytes'); + const enc=new TextEncoder(),chunks=[]; + function add(name,payload,type,mtime){const header=new Uint8Array(512);const text=(offset,s)=>header.set(enc.encode(s),offset),oct=(offset,len,n)=>text(offset,Math.floor(n).toString(8).padStart(len-1,'0')+'\0'); + text(0,name);oct(100,8,420);oct(108,8,0);oct(116,8,0);oct(124,12,payload.length);oct(136,12,Math.min(mtime,0x1ffffffff));text(148,' ');text(156,type);text(257,'ustar\0');text(263,'00');oct(148,7,header.reduce((a,b)=>a+b,0));header[155]=32; + chunks.push(header,payload,new Uint8Array((512-payload.length%512)%512));} + const value='mtime='+(entry.modified_ms/1000).toFixed(3)+'\n';let length=value.length+3;while(length!==String(length).length+1+value.length)length=String(length).length+1+value.length; + add('PaxHeader',enc.encode(length+' '+value),'x',Math.floor(entry.modified_ms/1000));add(entry.name,bytes,'0',Math.floor(entry.modified_ms/1000));chunks.push(new Uint8Array(1024));return new Blob(chunks,{type:'application/x-tar'}); + } + function create(o){ + const {body,node,button,select,api,pin,snapshot,peer}=o; + let disposed=false,busy=false,generation=0,owner='',files=[],revision=0,loaded=false,prompt=null; + const volume=select([['littlefs','LittleFS'],['sd','microSD']]),filter=node('input'),rows=node('div','file-browser-list'),notice=node('p','status-note'),progress=node('progress'); + filter.type='search';filter.placeholder='Filter files';filter.setAttribute('aria-label','Filter files');progress.max=1;progress.value=0;progress.hidden=true;notice.setAttribute('role','status'); + const picker=node('input');picker.type='file';picker.hidden=true; + const key=()=>{const s=snapshot();return s?.board_id&&s?.boot_id?s.board_id+':'+s.boot_id:'';}; + const assertCurrent=ctx=>{if(disposed||ctx.generation!==generation||ctx.owner!==key()||ctx.volume!==volume.value||!peer().has('managed-files'))throw Error('Device session changed; transfer stopped');}; + const context=()=>({generation,owner:key(),volume:volume.value,peer:pin()}); + async function call(ctx,action,params={}){assertCurrent(ctx);const s=snapshot();const result=await api('/api/maintenance/request',{method:'POST',peer:ctx.peer,headers:{'Content-Type':'application/json'},body:JSON.stringify({board_id:s.board_id,boot_id:s.boot_id,method:'POST',path:'/api/storage/files',body:{volume:ctx.volume,action,...params}})});assertCurrent(ctx);if(result.accepted!==true)throw Error(result.error||'File operation rejected');return result;} + function sync(){refresh.disabled=busy||!key();upload.disabled=busy||!loaded;volume.disabled=busy;progress.hidden=!busy;} + async function task(run){if(busy)return;busy=true;notice.textContent='';progress.value=0;sync();try{const ctx=context();await run(ctx);}catch(e){notice.textContent=e.message;}finally{busy=false;sync();}} + function draw(){rows.replaceChildren();for(const entry of files.filter(f=>f.name.toLowerCase().includes(filter.value.toLowerCase()))){const row=node('div','tools file-browser-row'),label=node('span','file-browser-name',entry.name),detail=node('span','muted',entry.size+' B · '+new Date(entry.modified_ms).toLocaleString());label.title=entry.name; + const save=archiveMode=>task(async ctx=>{const parts=[];let offset=0;while(offsetURL.revokeObjectURL(url),1000);notice.textContent='Download verified';}); + const download=button('Download',()=>root.WebUI.menu(download,[{label:'Download file',icon:'download',action:()=>save(false)},{label:'Download archive (.tar)',icon:'download',action:()=>save(true)}],{label:'Download options'}),'download'); + download.setAttribute('aria-haspopup','menu');row.append(label,detail,download);rows.append(row);} + if(!rows.children.length)rows.append(node('p','muted','No files'));} + async function list(ctx){let offset=0,found=[],r,seenRevision;do{r=await call(ctx,'list',{offset});if(!Array.isArray(r.files)||r.files.length>8||!Number.isInteger(r.revision)||r.next_offset!==offset+r.files.length||r.more&&!r.files.length||seenRevision!==undefined&&seenRevision!==r.revision)throw Error('File index changed; reload');seenRevision=r.revision;found.push(...r.files);if(found.length>32)throw Error('File index too large');offset=r.next_offset;}while(r.more); + for(const f of found)if(!validName(f.name)||!Number.isInteger(f.size)||f.size<0||f.size>131072||!Number.isSafeInteger(f.modified_ms)||f.modified_ms<0||!/^[0-9a-f]{64}$/.test(f.sha256))throw Error('Invalid file index');files=found;revision=r.revision;loaded=true;draw();} + const refresh=button('Reload',()=>task(list),'refresh'),upload=button('Upload file',()=>picker.click(),'upload'); + picker.onchange=()=>task(async ctx=>{const file=picker.files?.[0];picker.value='';if(!file)return;if(!validName(file.name)||file.size>131072)throw Error('Use a simple filename (letters, numbers, dash, underscore, dot), up to 128 KiB'); + const existing=files.find(f=>f.name===file.name); + if(existing){if(!root.WebUI?.dialog)throw Error('Replacement confirmation is unavailable'); + const approved=await new Promise(resolve=>{prompt=root.WebUI.dialog({title:'Replace '+file.name+'?',description:'The previous saved file will be replaced after byte verification.',actions:[{label:'Cancel',icon:'close',autofocus:true},{label:'Replace',icon:'upload',variant:'primary',action:()=>resolve(true)}]});prompt.element.addEventListener('close',()=>{prompt=null;resolve(false);},{once:true});}); + if(!approved)return;assertCurrent(ctx); + } + const bytes=new Uint8Array(await file.arrayBuffer());assertCurrent(ctx);const hash=sha256(bytes),start=await call(ctx,'begin',{name:file.name,size:bytes.length,sha256:hash,modified_ms:file.lastModified,revision,...(existing?{replace_sha256:existing.sha256}:{})}); + let done=false;try{for(let offset=0;offset{generation++;loaded=false;files=[];draw();void task(list);}; + const tools=node('div','tools row');tools.append(volume,refresh,upload,filter);body.append(tools,picker,progress,notice,rows); + return {update(){const next=key();if(next!==owner){owner=next;generation++;prompt?.close();files=[];loaded=false;draw();}const available=peer().known&&peer().has('managed-files');volume.options[1].disabled=!peer().has('microsd-files');if(!available)o.card.hidden=true;else if(!o.registerPanel)o.card.hidden=false;sync();},dispose(){disposed=true;generation++;prompt?.close();o.card.remove();}}; + } + root.UserFiles={sha256,hex,unhex,validName,archive,create}; +})(typeof window==='undefined'?globalThis:window); + // One Web application: these workspaces use the existing peer owner, API client, // command transport and render lifecycle. No connection, polling or front-panel // implementation is created here. Correction semantics are adapted from the @@ -1519,6 +1750,8 @@ const corrections=card('pixelCorrectionsCard','Pixel corrections','pixel-corrections','corrections'); const diagnostics=card('pixelDiagnosticsCard','Pixel diagnostics','pixel-diagnostic','diagnostics'); const inspector=card('apiInspectorCard','API inspector','identity','api'); + const fileCard=root.UserFiles?card('userFilesCard','Files','managed-files','folder'):null; + const userFiles=fileCard?root.UserFiles.create({...options,...fileCard,node,button,select,api,pin,snapshot,peer}):null; function reveal(target){if(!target)return;if(options.reveal)options.reveal(target);else root.studioReveal?.(target);target.scrollIntoView({behavior:'smooth',block:'start'});} const groupCard=root.PixelGroups?card('pixelGroupsCard','Pixel groups','pixel-group-layout','mapping'):null; const groups=groupCard?root.PixelGroups.create({...options,...groupCard,node,field,numeric,button,select,snapshot,peer,api,command,pin,report,reveal,diagnostics:diagnostics.card,corrections:corrections.card}):null; @@ -1539,7 +1772,20 @@ rows.tabIndex=0;rows.setAttribute('role','region');rows.setAttribute('aria-label',translate('Correction rules')); corrections.body.append(fixed,rows,mapDisclosure); function message(text,error=false){notice.textContent=translate(text);notice.classList.toggle('field-error',error);} - function changed(){if(!model)return;correctionEdits++;model.dirty=true;message(model.conflict?'Board changed elsewhere. Reload before saving.':'Unsaved changes',model.conflict);sync();if(model.mappingView==='draft')drawMapping();rememberPresentation();} + const livePreview=input('checkbox');livePreview.checked=true; + const livePreviewField=field('Live preview',livePreview);fixed.append(livePreviewField); + const preview=root.createPixelCorrectionPreview?.({ + owner:()=>current(owner)?{key:owner,pin:pin?.()}:null, + allowed:()=>current(owner)&&peer().has('pixel-corrections-preview')&&!!model?.saved&&!model.conflict&&validCorrectionDraft(model), + configuration:()=>({revision:model.saved.revision,enabled:model.enabled,rules:model.rules}), + pixels:()=>model.saved.pixels,project:correctionSources, + send:async(value,identity)=>{requireCurrent(identity.key);const reply=await command(correctionPath+'/preview',{configuration:JSON.stringify(value)});requireCurrent(identity.key);return reply.pixel_corrections_preview?{revision:value.revision,preview:reply.pixel_corrections_preview}:reply;}, + read:async identity=>{requireCurrent(identity.key);return api(correctionPath,identity.pin?{peer:identity.pin}:{});}, + onChange:state=>{if(state.error)message(state.error,true);} + }); + livePreview.onchange=()=>{if(!livePreview.checked)void preview?.stop();else if(model?.dirty)startPreview();}; + function startPreview(){if(!livePreview.checked||!preview||!validCorrectionDraft(model)||model.conflict)return;if(preview.snapshot().enabled)preview.edited();else void preview.start();} + function changed(){if(!model)return;correctionEdits++;model.dirty=true;message(model.conflict?'Board changed elsewhere. Reload before saving.':'Unsaved changes',model.conflict);sync();if(model.mappingView==='draft')drawMapping();rememberPresentation();startPreview();} enabled.onchange=()=>{if(!writable()||!model?.saved)return;model.enabled=enabled.checked;changed();}; const validNumericDraft=(text,min,max)=>text===undefined||text.trim()!==''&&Number.isSafeInteger(Number(text))&&Number(text)>=min&&Number(text)<=max; const validCorrectionDraft=record=>!!record?.saved&&record.rules.every(rule=>validNumericDraft(rule.pixelDraft,1,record.saved.pixels)&&(rule.kind!=='shift'||validNumericDraft(rule.offsetDraft,-record.saved.pixels,record.saved.pixels))); @@ -1582,14 +1828,18 @@ filter.oninput=()=>{drawMapping();rememberPresentation();};mapDisclosure.ontoggle=()=>{drawMapping();rememberPresentation();}; const rowRefreshers=[],cursorActions=[],rulePreviews=[];let correctionEdits=0,addressContextKey=''; function refreshRulePreviews(){if(!rulePreviews.some(view=>view.open()))return;const key=mappingColorKey(),rgb=correctionRgb(snapshot(),model?.saved?.pixels,peer().role,current(owner));for(const view of rulePreviews)view.refresh(key,rgb);} - // Flutter selectedDiagnosticPixel/importPixel: copy the selected OUTPUT - // cursor, never its projected raw source or a second 'lit pixel' guess. + // Diagnostics select RAW inputs. Rules select WIRE destinations. Resolve + // through the saved map; never migrate existing rule coordinates. function selectedCorrectionCursor(){ const p=model?.probe,s=diagnosticShape(snapshot(),peer().role); if(!current(owner)||!peer().has('pixel-diagnostic')||!s||!p||(!p.dirty&&!p.observation))return null; if(position.value===''||!position.checkValidity())return null; const index=Number(position.value)-1,used=s.layout?.used_pixels||s.pixels; - return Number.isInteger(index)&&index>=0&&index=used||model.conflict||(model.dirty&&preview?.snapshot().enabled))return null; + const sources=model.saved?.sources; + if(!Array.isArray(sources)||sources.length!==model.saved.pixels)return null; + const destinations=sources.flatMap((source,wire)=>source===index?[wire]:[]); + return destinations.length===1?destinations[0]:null; } function refreshCursorActions(){const index=selectedCorrectionCursor();for(const refresh of cursorActions)refresh(index);} function drawRows(){ @@ -1669,6 +1919,9 @@ try{ const lease=pin?.(); const wanted={revision:record.saved?.revision,enabled:record.enabled,rules:record.rules.map(({kind,pixel,offset,enabled})=>({kind,pixel,offset,enabled}))}; + // Retire RAM preview before explicit save/reload. A failed release + // leaves the draft intact and never overwrites the stored rules. + if(preview&&(preview.snapshot().enabled||preview.snapshot().lease!==null||preview.snapshot().pending)&&!await preview.stop())throw Error('Preview release not confirmed; retry after it expires'); if(save)await command(correctionPath,{configuration:JSON.stringify(wanted)}); requireCurrent(expected);const result=parseCorrections(await api(correctionPath,lease?{peer:lease}:{}));requireCurrent(expected); if(save&&(!correctionsMatch(result,wanted)||result.revision{ + await new Promise(resolve=>{ const prompt=ui().dialog({title:translate('Discard correction edits?'),closeLabel:translate('Close'), description:translate('Reload keeps the saved board configuration and discards only these edits.'), actions:[{label:translate('Cancel'),icon:'close',autofocus:true}, - {label:translate('Reload'),icon:'refresh',variant:'primary',action:()=>resolve(true)}]}); + {label:translate('Reload'),icon:'refresh',variant:'primary',action:async()=>{ + if(reloadPrompt!==prompt||!current(expected)||model!==record||!writable())return false; + // Keep the modal's async owner until the read restores enabled + // controls, so native close can return focus to Reload. + await load();resolve(true); + }}]}); reloadPrompt=prompt; prompt.element.addEventListener('close',()=>{if(reloadPrompt===prompt)reloadPrompt=null;resolve(false);},{once:true}); }); - if(!approved)return; + return; } requireCurrent(expected); if(model!==record)throw Error('Device session changed. Edits were kept.'); @@ -1707,6 +1965,7 @@ const reload=button('Reload',reloadCorrections,'refresh');tools.append(add,save,reload); function sync(){ const ready=writable()&&!!model?.saved; + livePreviewField.hidden=!preview||!peer().has('pixel-corrections-preview');livePreview.disabled=!ready; for(const control of rows.querySelectorAll('input,select,button'))control.disabled=!ready||control.dataset.boundary==='true'; enabled.disabled=!ready;add.disabled=!ready||model.rules.length>=32;save.disabled=!ready||!model.dirty||model.conflict||!validCorrectionDraft(model);reload.disabled=!current(owner)||!peer().has('pixel-corrections')||model?.busy===true; diagnosticLink.disabled=!peer().has('pixel-diagnostic')&&!peer().has('streaming'); @@ -1850,6 +2109,7 @@ const link=button('Pixel corrections',()=>reveal(corrections.card),'corrections');link.dataset.openCorrections='true';diagnostic.append(link);} function update(value=snapshot()){ if(disposed)return;const next=identity(value),c=peer(); + preview?.observe(); if(reloadPrompt&&(!c.known||!c.has('pixel-corrections')))dismissReload(); if(next!==owner){dismissReload();owner=next;model=next?models.get(next):null; if(next&&!model){const scope=options.workspaceScope?.(value)||'',draft=scope?diagnosticWorkspace(options.readWorkspace?.(scope)):null,presentation=pixelToolsWorkspace(scope?options.readPresentation?.(scope):null)||pixelToolsWorkspace({}); @@ -1867,14 +2127,14 @@ if(!model.attempted&&!model.busy)void load(); } for(const link of document.querySelectorAll('[data-open-corrections]'))link.hidden=!c.has('pixel-corrections'); - for(const link of groupLinks)link.hidden=!c.has('pixel-group-layout');groups?.update(value); + for(const link of groupLinks)link.hidden=!c.has('pixel-group-layout');groups?.update(value);userFiles?.update(); observeProbe(value);inspectRoutes();sync();syncProbe();syncInspector(); if(mapDisclosure.open&&mappingKey()!==mapStateKey)drawMapping(); if(c.known&&endpoints.value==='/api/status'&&!inspectorBusy){inspectorPayload=redact(value);response.textContent=JSON.stringify(inspectorPayload,null,2);inspectorStatus.textContent=translate('Live snapshot');} } const beforeUnload=event=>{if(groups?.dirty()||[...models.values()].some(record=>record.dirty||record.busy)){event.preventDefault();event.returnValue='';}}; root.addEventListener('beforeunload',beforeUnload); - installed={update,workspaceSnapshot(scope){return model&&model.scope===scope?diagnosticWorkspace(model.diagnosticDraft):undefined;},presentationSnapshot(scope){return model&&model.scope===scope?pixelToolsWorkspace(model.presentation):undefined;},dispose(){disposed=true;dismissReload();root.removeEventListener('beforeunload',beforeUnload);groups?.dispose();corrections.card.remove();diagnostics.card.remove();inspector.card.remove();installed=null;}}; + installed={update,workspaceSnapshot(scope){return model&&model.scope===scope?diagnosticWorkspace(model.diagnosticDraft):undefined;},presentationSnapshot(scope){return model&&model.scope===scope?pixelToolsWorkspace(model.presentation):undefined;},dispose(){void preview?.dispose();disposed=true;dismissReload();root.removeEventListener('beforeunload',beforeUnload);groups?.dispose();userFiles?.dispose();corrections.card.remove();diagnostics.card.remove();inspector.card.remove();installed=null;}}; update();return installed; } root.createFeatureWorkspaces=create; @@ -2051,7 +2311,7 @@ const sidebar=make('aside','studio-sidebar'),brand=make('div','studio-wordmark'),brandImage=make('img'),brandCopy=make('div');brandImage.src='/app-icon.png';brandImage.width=30;brandImage.height=30;brandImage.alt='';const brandTitle=make('strong'),brandCaption=make('small');brandTitle.textContent='PakarController';copy(brandCaption,'Workspace');brandCopy.append(brandTitle,brandCaption);brand.append(brandImage,brandCopy); const nav=make('nav');nav.id='studioNav';nav.setAttribute('aria-label','Workspace');sidebar.append(brand,nav); const content=make('div','studio-content'),heading=make('div','studio-page-heading'),pageTitle=make('h2'),pageActions=make('div','studio-page-actions');pageTitle.id='studioPageTitle';heading.append(pageTitle,pageActions);content.append(heading); - // Keep runtime insertion anchors alive. renderStateAppearance owns .layout .stack. + // Keep runtime insertion anchors alive. Dynamic panels mount on .layout. for(const node of [...workspace.children])if(node!==top&&node!==layout&&node.id!=='applicationActivity')content.append(node); content.append(layout);const activity=document.getElementById('applicationActivity');if(activity)content.append(activity);workspace.append(sidebar,top,content); const route=studioRoute({location:window.location,history:window.history,onNavigate:id=>navigate(id,false)}); @@ -2257,8 +2517,11 @@ function renderLcd(s){const root=document.getElementById('lcdPreview');if(!root)return;const v=lcdObservation(s);root.textContent=v?v.lines.join('\n'):''} function currentPeer(){return peerAvailability(state,contextReceivedAt>0&&Date.now()-contextReceivedAt<8000)} function contextAllows(path,data={}){ + if(path.startsWith('/api/minigame/'))return currentPeer().known&¤tPeer().role==='first-wins'&&state.minigame?.ready===true&¤tPeer().has('command-ack'); if(path==='/api/pixels/groups')return currentPeer().has('pixel-group-layout'); if(path==='/api/pixels/corrections')return currentPeer().has('pixel-corrections'); + if(path==='/api/pixels/corrections/preview')return currentPeer().has('pixel-corrections-preview'); + if(path==='/api/presentation')return currentPeer().has('host-presentation'); if(path==='/api/led/diagnostic')return currentPeer().has('pixel-diagnostic'); if(path==='/api/status-rgb')return currentPeer().has('status-rgb'); if(path==='/api/game/press')return currentPeer().has('game-press'); @@ -2422,7 +2685,7 @@ } if(modeRow){modeRow.hidden=!modeKnown;const select=document.getElementById('ledOutputMode');select.disabled=ledOutputPending||!peerHttp.writable(); document.getElementById('rainbowTest').disabled=select.disabled;if(modeKnown){select.value=state.led_output.mode; - setUiText(document.getElementById('ledOutputModeHint'),state.led_output.mode==='static'?'Static cells: six matching pixels. Rainbow test animates explicitly.':'Effects: animated output enabled explicitly.')}} + setUiText(document.getElementById('ledOutputModeHint'),state.led_output.mode==='static'?'Static groups: matching pixels. Rainbow and Fire animate when selected.':'Effects: animated output enabled explicitly.')}} } function upgradeObservation(u,s){ const phases=['queued','manifest','receiving','verifying','ready','prepare','download_esp','download_avr','backup_isp','program_isp','backup_flash','backup_eeprom','verify_flash','verify_eeprom','program_avr','program_esp','verify_esp','awaiting_application','awaiting_reboot','ready-to-reboot','verify_pair','release_pair','complete','failed','aborted','cancelled']; @@ -2449,14 +2712,28 @@ } // Read-only is an interaction guard, not an unavailable live-state indicator. // BEGIN KEY GAME PRESS CONTROLS -const keyGameControl={owner:'',readOnly:true,pending:null}; +const keyGameControl={owner:'',readOnly:true,pending:null,hold:null}; function syncKeyGameOwner(){ const c=currentPeer(); const owner=c.known&&c.role==='first-wins'&&peerHttp.writable()?String(state.board_id)+'/'+String(state.boot_id):''; - if(owner!==keyGameControl.owner){keyGameControl.owner=owner;keyGameControl.readOnly=true;keyGameControl.pending=null;} + if(owner!==keyGameControl.owner){keyGameControl.owner=owner;keyGameControl.readOnly=true;keyGameControl.pending=null;keyGameControl.hold=null;} +} +function miniGameObservation(){ + const game=state.minigame,c=currentPeer(); + return c.known&&c.role==='first-wins'&&game&&Array.isArray(game.available_modes)&& + game.available_modes.length>0&&game.available_modes.every(mode=>typeof mode.id==='string'&&typeof mode.label==='string')&& + game.available_modes.some(mode=>mode.id===game.mode)&&game.settings&&typeof game.phase==='string'?game:null; +} +function canChangeMiniGame(){return !!miniGameObservation()&&state.minigame.ready===true&&peerHttp.writable()&¤tPeer().has('command-ack')&&state.programming_latch!==true&&!keyGameControl.readOnly&&!keyGameControl.pending&&!keyGameControl.hold;} +async function changeMiniGame(kind,values){ + syncKeyGameOwner();if(!canChangeMiniGame())return false; + const pending={owner:keyGameControl.owner};keyGameControl.pending=pending;renderKeyGame(); + try{return await post('/api/minigame/'+kind,values)} + finally{if(keyGameControl.pending===pending)keyGameControl.pending=null;renderKeyGame();} } function canPressGameKey(key){ const c=currentPeer(); + if(state.minigame){const game=miniGameObservation();return !!game&&canChangeMiniGame()&&Number.isInteger(key)&&key>=1&&key<=9&&(game.participating_mask&(1<<(key-1)))!==0;} return !keyGameControl.readOnly&&!keyGameControl.pending&&peerHttp.writable()&& c.known&&c.role==='first-wins'&&c.has('game-press')&&state.programming_latch!==true&&state.key_mask_applied===true&& state.winner===0&&Number.isInteger(key)&&key>=1&&key<=9&& @@ -2465,11 +2742,122 @@ async function pressGameKey(key){ syncKeyGameOwner(); if(!canPressGameKey(key))return; + if(state.minigame)return changeMiniGame('input',{key,action:'tap'}); const pending={owner:keyGameControl.owner};keyGameControl.pending=pending;renderKeyGame(); try{await post('/api/game/press',{key});} finally{if(keyGameControl.pending===pending)keyGameControl.pending=null;renderKeyGame();} } +function miniGameMomentary(){const game=miniGameObservation();return game?.mode==='color-match'||game?.mode==='toggle'&&game.settings.toggle_kind==='press';} +function holdMiniGameKey(key){ + syncKeyGameOwner();if(!miniGameMomentary()||!canPressGameKey(key))return; + const hold={owner:keyGameControl.owner,key,releasing:false}; + const sent=changeMiniGame('input',{key,action:'down'});keyGameControl.hold=hold;hold.sent=sent; + renderKeyGame(); +} +async function releaseMiniGameKey(key){ + const hold=keyGameControl.hold;if(!hold||hold.key!==key||hold.releasing)return; + hold.releasing=true;await hold.sent;syncKeyGameOwner(); + // Release the original input even if read-only was selected while held. + if(keyGameControl.hold!==hold)return; + keyGameControl.hold=null; + if(hold.owner===keyGameControl.owner&&miniGameObservation()?.ready===true&&peerHttp.writable()){ + const pending={owner:hold.owner};keyGameControl.pending=pending;renderKeyGame(); + try{await post('/api/minigame/input',{key,action:'up'})} + finally{if(keyGameControl.pending===pending)keyGameControl.pending=null;renderKeyGame();} + } +} // END KEY GAME PRESS CONTROLS +// BEGIN MINIGAME SETTINGS +// Adapt the donor EffectGroupDialog local-draft/explicit-save separation through +// the existing key-game card. Status reads and opening settings never start play. +const miniGameFields={limit_ms:['Time limit (ms · 0 = off)',0,86400000],warning_ms:['Warning remaining (ms)',0,86400000],urgent_ms:['Urgent remaining (ms)',0,86400000],penalty_ms:['Mistake penalty (ms)',0,86400000],window_ms:['Reaction window (ms)',100,60000],show_ms:['Show time (ms)',100,10000],gap_ms:['Show gap (ms)',50,5000],feedback_ms:['Feedback time (ms)',50,5000],rounds:['Rounds',1,32],sequence_length:['Sequence length',1,32],palette_size:['Palette colors',2,9],fixed_target:['Target box (0 = random)',0,9]}; +Object.assign(miniGameFields,{intro_ms:['Reveal fade per box (ms)',100,10000],preview_ms:['Pairs preview hold (ms)',0,10000],mismatch_ms:['Wrong pair feedback (ms)',600,10000]}); +const miniModeDetails={ + 'press-to-win':['pointer','First press wins; reset for the next round'], + illuminated:['sun','Press the glowing target before its window closes'], + 'color-match':['palette','Cycle button colors to match the indicator colors'], + simon:['melody','Watch, remember and repeat the light sequence'], + 'memory-pairs':['copy','Reveal colors and find every matching pair'], + 'clear-all':['check','Press every illuminated button to clear the board'], + 'color-order':['selectAll','Press the boxes in palette color order'], + 'switch-pattern':['panels','Toggle buttons to copy the indicator pattern'], + 'reaction-chase':['bolt','Catch the moving light inside its response window'], + toggle:['power','Control latched or momentary outputs without a timer'], +}; +function renderMiniGame(card){ + const game=miniGameObservation();let root=card.querySelector('[data-minigame]'); + if(!root&&game){ + root=document.createElement('div');root.dataset.minigame=''; + root.innerHTML='

Advanced game settings
Output mapping

Saving resets the round to ready.

'; + card.querySelector('[data-keys]').insertAdjacentElement('beforebegin',root); + for(const [name,[label,min,max]] of Object.entries(miniGameFields)){ + const row=document.createElement('label');row.className='key-number';row.textContent=label; + const input=document.createElement('input');input.type='number';input.name=name;input.min=min;input.max=max;input.step=1;row.append(input);root.querySelector('[data-mini-fields]').append(row); + } + for(const action of ['start','pause','resume','reset','stop']){ + const button=document.createElement('button');button.type='button';button.dataset.miniAction=action;button.textContent=action[0].toUpperCase()+action.slice(1);button.onclick=()=>changeMiniGame('control',{action});root.querySelector('[data-mini-actions]').append(button); + } + const choice=document.createElement('button');choice.type='button';choice.className='mini-mode-choice';choice.dataset.miniChoice='';choice.setAttribute('aria-haspopup','menu');choice.setAttribute('aria-label','Game mode'); + const native=root.querySelector('[data-mini-mode]');native.hidden=true;native.parentElement.append(choice); + choice.onclick=()=>{const current=miniGameObservation();if(!current||!canChangeMiniGame())return; + WebUI.menu(choice,current.available_modes.map(mode=>({label:mode.label,description:mode.description||miniModeDetails[mode.id]?.[1]||'Board-provided game',icon:miniModeDetails[mode.id]?.[0]||'game',checked:mode.id===current.mode, + action:async()=>{if(mode.id!==miniGameObservation()?.mode)await changeMiniGame('configure',{settings:{mode:mode.id}});renderKeyGame();}})),{label:'Game mode'}); + }; + const form=root.querySelector('form'); + for(const [name,title] of [['intro_enabled','Reveal colors and notes before play'],['color_arpeggio','Arpeggio color notes']]){ + const label=document.createElement('label');label.className='key-option';const input=document.createElement('input');input.type='checkbox';input.name=name;label.append(input,document.createTextNode(title));form.querySelector('[data-mini-fields]').after(label); + } + for(let index=0;index<8;index++){ + const row=document.createElement('div');row.className='row';row.dataset.miniOutput=index; + const sourceLabel=document.createElement('label');sourceLabel.className='key-number';sourceLabel.textContent='Output '+(index+1); + const source=document.createElement('select');source.name='source'+index; + for(const value of ['disabled','active','success','failure','held','latched','winner','lost']){const option=document.createElement('option');option.value=value;option.textContent=value[0].toUpperCase()+value.slice(1);source.append(option);} + sourceLabel.append(source);const keyLabel=document.createElement('label');keyLabel.className='key-number';keyLabel.textContent='Box'; + const key=document.createElement('input');key.name='key'+index;key.type='number';key.min=1;key.max=9;key.step=1;keyLabel.append(key);row.append(sourceLabel,keyLabel);root.querySelector('[data-mini-outputs]').append(row); + source.onchange=()=>{key.disabled=!['held','latched','winner','lost'].includes(source.value);}; + } + form.addEventListener('input',()=>{form.dataset.dirty='true'}); + form.addEventListener('change',()=>{form.dataset.dirty='true'}); + root.querySelector('[data-mini-mode]').onchange=async event=>{ + const mode=event.currentTarget.value;if(miniGameObservation()?.available_modes.some(item=>item.id===mode))await changeMiniGame('configure',{settings:{mode}});renderKeyGame(); + }; + form.onsubmit=async event=>{ + event.preventDefault();if(form.dataset.owner!==keyGameControl.owner||form.dataset.mode!==miniGameObservation()?.mode||!canChangeMiniGame()||!form.reportValidity())return; + const observed=miniGameObservation(),settings={}; + for(const name of Object.keys(miniGameFields))if(Object.hasOwn(observed.settings,name))settings[name]=Number(form.elements[name].value); + for(const name of ['hard','intro_enabled','color_arpeggio'])if(Object.hasOwn(observed.settings,name))settings[name]=form.elements[name].checked; + if(Object.hasOwn(observed.settings,'toggle_kind'))settings.toggle_kind=form.elements.toggle_kind.value; + if(Array.isArray(observed.settings.outputs)&&observed.settings.outputs.length===8)settings.outputs=Array.from({length:8},(_,i)=>{const source=form.elements['source'+i].value;return {source,key:['held','latched','winner','lost'].includes(source)?Number(form.elements['key'+i].value):0}}); + const owner=keyGameControl.owner; + if(await changeMiniGame('configure',{settings})&&owner===keyGameControl.owner){delete form.dataset.dirty;renderKeyGame();} + }; + } + if(!root)return;root.hidden=!game;if(!game)return; + const enabled=canChangeMiniGame(),form=root.querySelector('form'),select=root.querySelector('[data-mini-mode]'); + const contextChanged=form.dataset.owner!==keyGameControl.owner||form.dataset.mode!==game.mode; + if(contextChanged){delete form.dataset.dirty;form.dataset.owner=keyGameControl.owner;form.dataset.mode=game.mode;} + const catalog=JSON.stringify(game.available_modes); + if(select.dataset.catalog!==catalog){select.replaceChildren();for(const mode of game.available_modes){const option=document.createElement('option');option.value=mode.id;option.textContent=mode.label;select.append(option)}select.dataset.catalog=catalog;} + select.value=game.mode;select.disabled=!enabled||form.dataset.dirty==='true'; + const choice=root.querySelector('[data-mini-choice]'),selectedMode=game.available_modes.find(mode=>mode.id===game.mode),details=miniModeDetails[game.mode]||['game','Board-provided game']; + choice.disabled=select.disabled;choice.replaceChildren(WebUI.icon(details[0]));const copy=document.createElement('span'),title=document.createElement('strong'),description=document.createElement('small');title.textContent=selectedMode.label;description.textContent=selectedMode.description||details[1];copy.append(title,description);choice.append(copy,WebUI.icon('chevron')); + for(const button of root.querySelectorAll('[data-mini-action]')){ + const action=button.dataset.miniAction,running=['show','input','feedback'].includes(game.phase); + button.hidden=action==='pause'&&!running||action==='resume'&&game.phase!=='paused'||action==='start'&&(running||game.phase==='paused');button.disabled=!enabled; + } + const number=value=>Number.isSafeInteger(value)?String(value):'—'; + root.querySelector('[data-mini-status]').textContent=[game.phase,game.ready===true?null:'Board not ready',game.timed===true?'Remaining '+number(game.remaining_ms)+' ms':null,'Elapsed '+number(game.elapsed_ms)+' ms','Round '+number(game.round),'Progress '+number(game.progress),'Mistakes '+number(game.mistakes),game.winner?'Winner · Box '+game.winner:null,game.target?'Target · Box '+game.target:null,game.warning?'Warning '+number(game.warning):null,'Reaction '+number(game.last_reaction_ms)+' ms','Best '+number(game.best_reaction_ms)+' ms'].filter(Boolean).join(' · '); + if(contextChanged||form.dataset.dirty!=='true'&&!form.contains(document.activeElement)){ + for(const name of Object.keys(miniGameFields))form.elements[name].value=game.settings[name]??''; + for(const name of ['hard','intro_enabled','color_arpeggio'])form.elements[name].checked=game.settings[name]===true;form.elements.toggle_kind.value=game.settings.toggle_kind??''; + for(let i=0;i<8;i++){const rule=game.settings.outputs?.[i];form.elements['source'+i].value=rule?.source??'disabled';form.elements['key'+i].value=rule?.key||1;} + } + for(const [name] of Object.entries(miniGameFields)){const input=form.elements[name];input.parentElement.hidden=!Object.hasOwn(game.settings,name);input.disabled=!enabled||input.parentElement.hidden;} + for(const name of ['hard','intro_enabled','color_arpeggio','toggle_kind']){const input=form.elements[name];input.parentElement.hidden=!Object.hasOwn(game.settings,name);input.disabled=!enabled||input.parentElement.hidden;} + for(const row of root.querySelectorAll('[data-mini-output]')){const i=Number(row.dataset.miniOutput),source=form.elements['source'+i],key=form.elements['key'+i];row.hidden=game.settings.outputs?.length!==8;source.disabled=!enabled||row.hidden;key.disabled=!enabled||row.hidden||!['held','latched','winner','lost'].includes(source.value);} + form.querySelector('[type=submit]').disabled=!enabled; +} +// END MINIGAME SETTINGS function renderKeyGame(){ syncKeyGameOwner(); let card=document.getElementById('keyGameCard'); @@ -2490,11 +2878,16 @@ `; contextMessage.insertAdjacentElement('afterend',card); for(let key=1;key<=9;key++){ - const indicator=document.createElement('button');indicator.type='button';indicator.dataset.key=key;indicator.textContent=key;indicator.setAttribute('aria-label','Press box '+key);indicator.onclick=()=>pressGameKey(key);card.querySelector('[data-keys]').append(indicator); + const indicator=document.createElement('button');indicator.type='button';indicator.dataset.key=key;indicator.textContent=key;indicator.setAttribute('aria-label','Press box '+key);indicator.onclick=()=>{if(!miniGameMomentary())pressGameKey(key)};card.querySelector('[data-keys]').append(indicator); + indicator.onpointerdown=event=>{if(event.button===0&&miniGameMomentary()&&canPressGameKey(key)){event.preventDefault();indicator.setPointerCapture(event.pointerId);holdMiniGameKey(key)}}; + indicator.onpointerup=indicator.onpointercancel=indicator.onlostpointercapture=()=>releaseMiniGameKey(key); + indicator.onkeydown=event=>{if(!event.repeat&&[' ','Enter'].includes(event.key)&&miniGameMomentary()){event.preventDefault();holdMiniGameKey(key)}}; + indicator.onkeyup=event=>{if([' ','Enter'].includes(event.key)&&miniGameMomentary()){event.preventDefault();releaseMiniGameKey(key)}}; + indicator.onblur=()=>releaseMiniGameKey(key); const label=document.createElement('label'),input=document.createElement('input');input.type='checkbox';input.name='box'+key;label.append(input,document.createTextNode('Box '+key));card.querySelector('[data-enabled]').append(label); } card.querySelector('[data-key-readonly]').onchange=event=>{keyGameControl.readOnly=event.currentTarget.checked;renderKeyGame()}; - const form=card.querySelector('form'); + const form=card.querySelector('form');form.dataset.legacyKeySettings=''; const presentation=document.createElement('fieldset');presentation.className='key-setting-block'; presentation.innerHTML=`Round presentation
@@ -2543,19 +2936,25 @@ } const config=state.key_game,c=currentPeer();card.hidden=!(c.known&&c.role==='first-wins'&&config); if(card.hidden)return; + renderMiniGame(card); + const minigame=miniGameObservation(); + const heading=card.querySelector('h2');if(heading)heading.textContent=minigame?'Key games':'First press wins'; card.querySelector('[data-key-readonly]').checked=keyGameControl.readOnly; card.querySelector('[data-keys]').setAttribute('aria-busy',String(!!keyGameControl.pending)); - card.querySelector('[data-round-state]').textContent=state.winner?'Winner · Box '+state.winner:'Ready for first press'; - card.querySelector('[data-enabled-count]').textContent=Number(config.enabled_count)+' boxes enabled'; + card.querySelector('[data-round-state]').textContent=minigame?minigame.available_modes.find(mode=>mode.id===minigame.mode).label:state.winner?'Winner · Box '+state.winner:'Ready for first press'; + const enabledMask=minigame?minigame.participating_mask:config.enabled_mask; + const enabledCount=Array.from({length:9},(_,i)=>(Number(enabledMask)>>i)&1).reduce((a,b)=>a+b,0); + card.querySelector('[data-enabled-count]').textContent=enabledCount+' boxes enabled'; for(const indicator of card.querySelectorAll('[data-key]')){ - const key=Number(indicator.dataset.key),enabled=(config.enabled_mask&(1<<(key-1)))!==0; - indicator.disabled=!canPressGameKey(key); + const key=Number(indicator.dataset.key),enabled=(enabledMask&(1<<(key-1)))!==0; + indicator.disabled=!canPressGameKey(key)&&keyGameControl.hold?.key!==key; indicator.setAttribute('aria-disabled',String(indicator.disabled)); indicator.classList.toggle('box-disabled',!enabled); - indicator.classList.toggle('active',key===9?state.input_9===true:(Number(state.active_inputs)&(1<<(key-1)))!==0); - indicator.style.borderColor='#'+Number(state.winner?(state.winner===key?config.win_rgb:config.lose_rgb):config.idle_rgb).toString(16).padStart(6,'0'); + indicator.classList.toggle('active',minigame?((minigame.held_mask|minigame.latched_mask)&(1<<(key-1)))!==0:key===9?state.input_9===true:(Number(state.active_inputs)&(1<<(key-1)))!==0); + const winner=minigame?minigame.winner:state.winner; + indicator.style.borderColor='#'+Number(winner?(winner===key?config.win_rgb:config.lose_rgb):config.idle_rgb).toString(16).padStart(6,'0'); } - const form=card.querySelector('form'); + const form=card.querySelector('[data-legacy-key-settings]'); const owner=String(state.board_id)+'/'+String(state.boot_id); if(form.dataset.owner!==owner){delete form.dataset.dirty;form.dataset.owner=owner;} const sound=card.querySelector('[data-game-melodies]');sound.hidden=!c.has('game-melodies'); @@ -2607,7 +3006,7 @@ resetWhite.textContent=uiTr('Reset to white'); const gameCard=document.getElementById('keyGameCard'); if(gameCard&&resetWhite.parentElement!==gameCard)gameCard.querySelector('.row').append(resetWhite); - resetWhite.hidden=!(c.known&&c.role==='first-wins'&&c.has('reset-ack')&&c.has('inputs')&&c.has('outputs')); + resetWhite.hidden=!!state.minigame||!(c.known&&c.role==='first-wins'&&c.has('reset-ack')&&c.has('inputs')&&c.has('outputs')); resetWhite.disabled=resetWhite.dataset.pending==='true'||!peerHttp.writable()||state.avr_online!==true||state.programming_latch===true; if(typeof renderAssignmentTools==='function')renderAssignmentTools(); renderLedOutputControl(); @@ -2789,9 +3188,12 @@ let card=document.getElementById('stateAppearanceCard'); const available=currentPeer().has('state-presentation')&&state.state_presentation; if(!card&&available){ + const host=document.querySelector('.layout'); + if(!host)return; card=document.createElement('section');card.id='stateAppearanceCard';card.className='card'; + card.dataset.capability='state-presentation'; card.innerHTML='

State colors and effects

Temporary color test

'; - document.querySelector('.layout .stack').appendChild(card); + host.appendChild(card); card.querySelector('#stateEffectsEnabled').onchange=e=>saveStateAppearance({enabled:e.target.checked}); for(const name of ['idle','won','lost']){ const row=document.createElement('div');row.className='row'; @@ -2815,12 +3217,28 @@ } if(!card)return;card.hidden=!available;if(!available)return; const p=state.state_presentation,table=currentPeer().role==='table-selection'; + let fire=card.querySelector('#fireDirectionField'); + if(!fire){ + fire=document.createElement('label');fire.id='fireDirectionField';fire.className='field'; + const title=document.createElement('span');title.className='field-label'; + if(globalThis.WebUI?.icon)title.append(globalThis.WebUI.icon('fire')); + const text=document.createElement('span');setUiText(text,'Fire direction');title.append(text); + const select=document.createElement('select');select.id='fireDirection';select.setAttribute('aria-label',uiTr('Fire direction')); + for(const [angle,label]of [[0,'0° · Up'],[90,'90° · Right'],[180,'180° · Down'],[270,'270° · Left']]){ + const option=document.createElement('option');option.value=String(angle);setUiText(option,label);select.append(option); + } + select.onchange=()=>{const rotation=Number(select.value);select.value=String(state.state_presentation?.fire_rotation); + if(currentPeer().role==='table-selection'&&[0,90,180,270].includes(rotation))saveStateAppearance({fire_rotation:rotation});}; + fire.append(title,select);card.prepend(fire); + } + fire.hidden=!table||![0,90,180,270].includes(p.fire_rotation); + card.querySelector('#fireDirection').value=String(p.fire_rotation); for(const control of card.querySelectorAll('input,select,button'))control.disabled=presentationBusy||!authenticated||!peerHttp.writable(); card.querySelector('#stateEffectsEnabled').checked=p.enabled===true; for(const name of ['idle','won','lost']){ const color=card.querySelector('#stateColor-'+name),motion=card.querySelector('#stateMotion-'+name); if(document.activeElement!==color&&Number.isInteger(p.states?.[name]?.rgb))color.value='#'+p.states[name].rgb.toString(16).padStart(6,'0'); - if(document.activeElement!==motion)motion.value=String(p.states?.[name]?.motion??0); + motion.value=String(p.states?.[name]?.motion??0); motion.hidden=!table&&name==='idle'; } for(const name of ['fade_ms','pulse_ms','ring_ms','chase_ms']){const input=card.querySelector('#statePeriod-'+name);if(document.activeElement!==input)input.value=p[name];if(name==='ring_ms'){input.min=table?'100':'400';input.max=table?'20000':'3000';}} @@ -3271,7 +3689,7 @@ if(!contextAllows(path,data))throw Error('This board has not acknowledged this capability'); const receipt=await commandTransport.send(path,data); if(receipt){render(receipt.status);return receipt} - if(path==='/api/led/diagnostic')throw Error('Pixel diagnostics requires an acknowledged command channel'); + if(path==='/api/led/diagnostic'||path.startsWith('/api/minigame/'))throw Error('This action requires an acknowledged command channel'); return api(path,{method:'POST',peer:peerHttp.pin(),body:new URLSearchParams(data)}); } async function post(path,data={}){try{await controlRequest(path,data);if(path==='/api/led/output'&&Object.hasOwn(data,'backend'))await waitForLedBackend(data.backend,{board_id:state.board_id,boot_id:state.boot_id,command_clock_ms:state.command_clock_ms});else await refresh();commandHaptic('command.success');globalThis.pakarApplicationUi?.confirmed();return true}catch(e){commandHaptic('command.error');report(e);return false}} @@ -3280,7 +3698,7 @@ displayText.addEventListener('input',displayControls);displayScroll.addEventListener('change',displayControls);displayControls(); async function loadMappings(){try{mapping=await api('/api/cells/mapping');loadMappingEditor(Number(cellId.value)||1);if(state.hostname)render(state)}catch(e){report(e)}}function loadMappingEditor(id){mapLogical.value=id;mapPhysical.value=mapping.physical[id-1]||id;mapName.value=mapping.names[id-1]||id;if(typeof workspaceDrafts!=='undefined'&&Number(workspaceDrafts.mapLogical)===id)for(const field of ['mapPhysical','mapName'])if(typeof workspaceDrafts[field]==='string')workspaceNode(field).value=workspaceDrafts[field]}async function saveMapping(){await post('/api/cells/mapping',{logical_id:mapLogical.value,physical_cell:mapPhysical.value,name:mapName.value});await loadMappings()} function snakeDirection(value){post('/api/snake/direction',{value})} -async function scanWifi(){try{const x=await api('/api/wifi/scan');ssid.innerHTML='';for(const n of x.networks){const o=document.createElement('option');o.value=n.ssid;o.textContent=`${n.ssid} · ${n.rssi} dBm`;ssid.appendChild(o)}}catch(e){report(e)}} +async function scanWifi(){try{const owner=state?.board_id+'/'+state?.boot_id;let x=await api('/api/wifi/scan');for(let n=0;x.running&&n<30;n++){await new Promise(r=>setTimeout(r,500));if(owner!==state?.board_id+'/'+state?.boot_id)throw Error('Connection changed');x=await api('/api/wifi/scan?results=true')}if(owner!==state?.board_id+'/'+state?.boot_id)throw Error('Connection changed');if(x.running||x.error)throw Error(x.error||'Wi-Fi scan timed out');ssid.innerHTML='';for(const n of x.networks){const o=document.createElement('option');o.value=n.ssid;o.textContent=`${n.ssid} · ${n.rssi} dBm`;ssid.appendChild(o)}}catch(e){report(e)}} async function applyNetwork(){await settingsSave('network')} async function loadCloud(){try{await settingsLoad('cloud')}catch(e){report(e)}} async function applyCloud(){await settingsSave('cloud')} diff --git a/web/modules/components.js b/web/modules/components.js index 8b635fcd..d7313bd7 100644 --- a/web/modules/components.js +++ b/web/modules/components.js @@ -5,12 +5,14 @@ (() => { 'use strict'; const paths = { + folder:'M3 6V4h7l2 3h9v13H3zM3 8h18', controls:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z', table:'M3 4h18v16H3zM3 9h18M3 14h18M9 4v16M15 4v16', mapping:'M3 4h6v6H3zM13 4h8v6h-8zM3 14h10v6H3zM17 14h4v6h-4z', diagnostics:'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15m5.3 12.8L21 21M7 10.5h7m-3.5-3.5v7', search:'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15m5.3 12.8L21 21', effects:'m12 3 2.2 6.8L21 12l-6.8 2.2L12 21l-2.2-6.8L3 12l6.8-2.2z', + fire:'M13 2c1 5-4 6-3 10-2-1-3-3-3-4-4 4-4 9-1 12 4 4 12 1 12-5 0-4-2-7-5-13zM12 14c-3 2-3 6 0 7 3-1 3-4 0-7z', hardware:'M6 6h12v12H6zM9 2v4m6-4v4M9 18v4m6-4v4M2 9h4m-4 6h4m12-6h4m-4 6h4', settings:'M9 3h6l.7 3 2.5 1.5 3-.9 3 5.2-2.3 2.1v2.9l2.3 2.1-3 5.2-3-.9-2.5 1.5-.7 3H9l-.7-3-2.5-1.5-3 .9-3-5.2 2.3-2.1v-2.9L-.2 9.8l3-5.2 3 .9L8.3 4z', sliders:'M4 5h16M4 12h16M4 19h16M8 3v4m8 3v4M10 17v4', @@ -124,7 +126,7 @@ openMenu?.();const panel=node('div','web-menu');panel.setAttribute('role','menu');panel.setAttribute('aria-label',label);const previous=document.activeElement; const close=(restore=true)=>{panel.remove();document.removeEventListener('pointerdown',outside,true);document.removeEventListener('keydown',keys,true);window.removeEventListener('resize',resize);if(restore&&previous?.isConnected)previous.focus();anchor?.setAttribute?.('aria-expanded','false');if(openMenu===close)openMenu=null;}; const run=item=>{close();return item.action?.();}; - for(const item of items){if(item.separator){panel.append(node('div','web-menu-separator'));continue;}if(item.heading){panel.append(node('div','web-menu-label',item.heading));continue;}const b=button(item.label,()=>run(item),{className:'web-menu-item',disabled:item.disabled});b.replaceChildren(icon(item.checked?'check':item.icon||'controls'),node('span','',item.label),node('span','shortcut',item.shortcut||''));b.setAttribute('role',typeof item.checked==='boolean'?'menuitemcheckbox':'menuitem');if(typeof item.checked==='boolean')b.setAttribute('aria-checked',String(item.checked));if(item.danger)b.dataset.danger='true';panel.append(b);} + for(const item of items){if(item.separator){panel.append(node('div','web-menu-separator'));continue;}if(item.heading){panel.append(node('div','web-menu-label',item.heading));continue;}const b=button(item.label,()=>run(item),{className:'web-menu-item',disabled:item.disabled});const copy=node('span',item.description?'menu-copy':'',item.label);if(item.description)copy.append(node('small','',item.description));b.replaceChildren(icon(item.icon||'controls'),copy,node('span','shortcut',typeof item.checked==='boolean'?(item.checked?'✓':''):item.shortcut||''));b.setAttribute('role',typeof item.checked==='boolean'?'menuitemcheckbox':'menuitem');if(typeof item.checked==='boolean')b.setAttribute('aria-checked',String(item.checked));if(item.danger)b.dataset.danger='true';panel.append(b);} const outside=e=>{if(!panel.contains(e.target)&&!anchor?.contains?.(e.target))close(false);}; const keys=e=>{const list=[...panel.querySelectorAll('button:not(:disabled)')],i=list.indexOf(document.activeElement);if(e.key==='Escape'){e.preventDefault();close();}else if(['ArrowDown','ArrowUp','Home','End'].includes(e.key)){e.preventDefault();const next=e.key==='Home'?0:e.key==='End'?list.length-1:(i+(e.key==='ArrowDown'?1:-1)+list.length)%list.length;list[next]?.focus();}else if(e.key==='Tab')close(false);}; const resize=()=>close(false);document.body.append(panel);const r=anchor?.getBoundingClientRect?.()||{left:16,bottom:16};const pr=panel.getBoundingClientRect();panel.style.left=Math.max(8,Math.min(r.left,innerWidth-pr.width-8))+'px';panel.style.top=Math.max(8,Math.min(r.bottom+6,innerHeight-pr.height-8))+'px';anchor?.setAttribute?.('aria-expanded','true');document.addEventListener('pointerdown',outside,true);document.addEventListener('keydown',keys,true);window.addEventListener('resize',resize);panel.querySelector('button:not(:disabled)')?.focus();openMenu=close;return {element:panel,close};