From d332ad0607b354f0d4e6197612048d0757b413fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:03:23 +0000 Subject: [PATCH 01/72] chore(deps): bump the actions-minor-and-patch group across 1 directory with 5 updates Bumps the actions-minor-and-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` | | [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` | | [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` | Updates `actions/setup-node` from 7.0.0 to 7.1.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1) Updates `actions/download-artifact` from 8.0.1 to 8.0.2 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333) Updates `github/codeql-action/init` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) Updates `actions/upload-artifact` from 7.0.1 to 7.0.2 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch - dependency-name: actions/setup-node dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-and-patch - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch - dependency-name: github/codeql-action/analyze dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch - dependency-name: github/codeql-action/init dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 4 ++-- .github/workflows/codeql.yml | 6 +++--- .github/workflows/deploy-avr.yml | 10 +++++----- .github/workflows/firmware.yml | 6 +++--- .github/workflows/host.yml | 10 +++++----- .github/workflows/release.yml | 4 ++-- .github/workflows/repository-health.yml | 2 +- .github/workflows/update-dependencies.yml | 4 ++-- .github/workflows/virtual-board.yml | 8 ++++---- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b0dd5a5a..e62df9650 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -66,7 +66,7 @@ jobs: > build-downloads/artifact-index.json - name: "⬇️ Download build artifacts" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: PCController-* path: build-downloads diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c6c271fdf..6796d92a8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -92,7 +92,7 @@ jobs: - name: "🟢 Install Node.js" if: matrix.key == 'cpp-avr-linux' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -132,7 +132,7 @@ jobs: node -e 'const { execFileSync } = require("node:child_process"); for (const library of JSON.parse(process.env.ARDUINO_LIBRARIES_JSON)) execFileSync("arduino-cli", ["lib", "install", library], { stdio: "inherit" });' - name: "🛡️ Initialize CodeQL" - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build_mode }} @@ -200,7 +200,7 @@ jobs: if errorlevel 1 exit /b 1 - name: "🔬 Analyze and upload CodeQL results" - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: ${{ matrix.category }} diff --git a/.github/workflows/deploy-avr.yml b/.github/workflows/deploy-avr.yml index 6b02aef4a..10ab485b0 100644 --- a/.github/workflows/deploy-avr.yml +++ b/.github/workflows/deploy-avr.yml @@ -65,7 +65,7 @@ jobs: persist-credentials: false - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -145,7 +145,7 @@ jobs: - name: "⬆️ Upload immutable programming bundle" id: upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: PCController-Verified-Deployment-Bundle path: deployment-bundle @@ -213,7 +213,7 @@ jobs: cache-dependency-path: Tools/Controller/go.sum - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -253,7 +253,7 @@ jobs: printf 'Verified AVRDUDE backend: %s\n' "$avrdude" - name: "⬇️ Download verified programming bundle" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: PCController-Verified-Deployment-Bundle path: deployment-bundle @@ -317,7 +317,7 @@ jobs: - name: "⬆️ Preserve physical deployment evidence" if: always() id: evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: PCController-Deployment-Evidence-${{ github.run_id }} path: deployment-evidence diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index c62192f68..11b6405e3 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -44,7 +44,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -150,7 +150,7 @@ jobs: - name: "⬆️ Upload firmware artifact" id: upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: ${{ steps.pins.outputs.product_slug }}-Firmware-ATmega328P path: .build/actions-artifact/* @@ -207,7 +207,7 @@ jobs: node Tools/Firmware/targets.mjs build mega2560 expanded-autonomous - name: "⬆️ Preserve build-only ATmega2560 evidence (not a commissioned release)" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: BuildOnly-ATmega2560-Targets path: .build/firmware/mega2560 diff --git a/.github/workflows/host.yml b/.github/workflows/host.yml index 82de7b183..fad7712fe 100644 --- a/.github/workflows/host.yml +++ b/.github/workflows/host.yml @@ -135,7 +135,7 @@ jobs: ctest --test-dir .build/controller-cabi --output-on-failure - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: ${{ steps.host-dependencies.outputs.node_version }} @@ -148,7 +148,7 @@ jobs: - name: "⬇️ Download the exact validated firmware defaults" if: inputs.firmware_artifact_name != '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: ${{ inputs.firmware_artifact_name }} path: .build/firmware @@ -236,7 +236,7 @@ jobs: - name: "⬆️ Upload Host artifact" id: upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: PCController-Host-${{ matrix.target }} path: | @@ -263,7 +263,7 @@ jobs: run: node Tools/Dependencies/export-lock.mjs export-host --output "$GITHUB_OUTPUT" - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: ${{ steps.summary-host-dependencies.outputs.node_version }} @@ -288,7 +288,7 @@ jobs: - name: "⬇️ Download available Host artifacts" if: ${{ always() }} continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: PCController-Host-* path: build-summary/packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d09946d1..ad05c60d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,12 +99,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" - name: "⬇️ Download build artifacts" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: PCController-* path: release-downloads diff --git a/.github/workflows/repository-health.yml b/.github/workflows/repository-health.yml index a0a04a049..d8f048972 100644 --- a/.github/workflows/repository-health.yml +++ b/.github/workflows/repository-health.yml @@ -30,7 +30,7 @@ jobs: run: node Tools/Dependencies/export-lock.mjs export-host --output "$GITHUB_OUTPUT" - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: ${{ steps.host-dependencies.outputs.node_version }} diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml index 3da47d182..836257fd4 100644 --- a/.github/workflows/update-dependencies.yml +++ b/.github/workflows/update-dependencies.yml @@ -42,7 +42,7 @@ jobs: cache-dependency-path: Tools/Controller/go.sum - name: Install current Node.js LTS - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* cache: npm @@ -79,7 +79,7 @@ jobs: - name: Upload resolution, hash, and validation evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: dependency-resolution-${{ github.run_id }} path: | diff --git a/.github/workflows/virtual-board.yml b/.github/workflows/virtual-board.yml index 9f4e826a8..09a43a1c6 100644 --- a/.github/workflows/virtual-board.yml +++ b/.github/workflows/virtual-board.yml @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -108,7 +108,7 @@ jobs: - name: "⬆️ Upload Virtual Board artifact" id: upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: PCController-VirtualBoard-${{ matrix.target }} path: | @@ -130,7 +130,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: "🟢 Install Node.js" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" @@ -155,7 +155,7 @@ jobs: - name: "⬇️ Download available Virtual Board artifacts" if: ${{ always() }} continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: PCController-VirtualBoard-* path: build-summary/packages From 908b03308232006f100feeb4a72ed5abc85b321f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:37:28 +0000 Subject: [PATCH 02/72] chore(deps): bump motion from 13.5.1 to 14.0.0 in /Tools/Controller/web Bumps [motion](https://github.com/motiondivision/motion) from 13.5.1 to 14.0.0. - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v13.5.1...v14.0.0) --- updated-dependencies: - dependency-name: motion dependency-version: 14.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Tools/Controller/web/package-lock.json | 34 +++++++++++++------------- Tools/Controller/web/package.json | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Tools/Controller/web/package-lock.json b/Tools/Controller/web/package-lock.json index ddbec09f6..47dd773b3 100644 --- a/Tools/Controller/web/package-lock.json +++ b/Tools/Controller/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-development", "dependencies": { "lucide-react": "^1.47.0", - "motion": "^13.4.0", + "motion": "^14.0.0", "react": "^19.3.0", "react-dom": "^19.3.0", "recharts": "^3.10.1" @@ -1152,13 +1152,13 @@ } }, "node_modules/framer-motion": { - "version": "13.5.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.5.1.tgz", - "integrity": "sha512-HeWOaeHD3kyTSIEMT6+nw7fBn4saIfxxabZyAITbycAl4xr1kzGJKIvGt/OWghSlvIA9qTnZ1zTGelHAe6S8DA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.0.0.tgz", + "integrity": "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==", "license": "MIT", "dependencies": { - "motion-dom": "13.5.1", - "motion-utils": "13.5.0", + "motion-dom": "14.0.0", + "motion-utils": "14.0.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -1507,12 +1507,12 @@ } }, "node_modules/motion": { - "version": "13.5.1", - "resolved": "https://registry.npmjs.org/motion/-/motion-13.5.1.tgz", - "integrity": "sha512-eV9SUem6KnzEa9+eT+dVBeDlhy5TTdn09YWR1WhUsKoZCJPw/n4TcYhjbxq2Gy5PTwDr//4H3r5vxkcZnNnO1w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-14.0.0.tgz", + "integrity": "sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==", "license": "MIT", "dependencies": { - "framer-motion": "13.5.1", + "framer-motion": "14.0.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -1529,18 +1529,18 @@ } }, "node_modules/motion-dom": { - "version": "13.5.1", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.5.1.tgz", - "integrity": "sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.0.0.tgz", + "integrity": "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==", "license": "MIT", "dependencies": { - "motion-utils": "13.5.0" + "motion-utils": "14.0.0" } }, "node_modules/motion-utils": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.5.0.tgz", - "integrity": "sha512-Hqe/sqjIKliPZg8sVjWAkdySDPJNIeKH+euVkDOG8ri3DUUU7ZjndmHsGqEPz2iHpiyVaV5I22iguCMHU2he+Q==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.0.0.tgz", + "integrity": "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==", "license": "MIT" }, "node_modules/nanoid": { diff --git a/Tools/Controller/web/package.json b/Tools/Controller/web/package.json index b5129cc29..92ab26d45 100644 --- a/Tools/Controller/web/package.json +++ b/Tools/Controller/web/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "lucide-react": "^1.47.0", - "motion": "^13.4.0", + "motion": "^14.0.0", "react": "^19.3.0", "react-dom": "^19.3.0", "recharts": "^3.10.1" From a37e2b89dbdf710f9820b76e583ebbf42f28f76f Mon Sep 17 00:00:00 2001 From: David Refoua Date: Mon, 12 Oct 2026 23:49:56 +0330 Subject: [PATCH 03/72] feat(virtual): support isolated Pealayer development and mapped host sensors --- LocalLib/DallasTemperatureBus.cpp | 2 +- Project/Ina219Sensor.cpp | 11 ++ Project/Runtime/ProtocolRuntime.inc.h | 3 + .../internal/appconfig/native_sensors.go | 24 ++++ .../internal/appconfig/native_sensors_test.go | 19 +++ Tools/Controller/internal/ipcjson/ipc.go | 6 + Tools/Controller/internal/native/responses.go | 3 + Tools/VirtualBoard/CMakeLists.txt | 3 +- Tools/VirtualBoard/README.md | 58 +++++++++- Tools/VirtualBoard/dev-host.mjs | 109 ++++++++++++++++++ .../include/virtual_board/sensor_sources.hpp | 17 +++ Tools/VirtualBoard/platform/Arduino.h | 3 + Tools/VirtualBoard/src/main.cpp | 9 +- Tools/VirtualBoard/src/native_firmware.cpp | 11 +- Tools/VirtualBoard/src/sensor_sources.cpp | 64 ++++++++++ .../tests/transport_integration.test.mjs | 37 +++++- 16 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 Tools/Controller/internal/appconfig/native_sensors.go create mode 100644 Tools/Controller/internal/appconfig/native_sensors_test.go create mode 100644 Tools/VirtualBoard/dev-host.mjs create mode 100644 Tools/VirtualBoard/include/virtual_board/sensor_sources.hpp create mode 100644 Tools/VirtualBoard/src/sensor_sources.cpp diff --git a/LocalLib/DallasTemperatureBus.cpp b/LocalLib/DallasTemperatureBus.cpp index f26e69215..154f35de0 100644 --- a/LocalLib/DallasTemperatureBus.cpp +++ b/LocalLib/DallasTemperatureBus.cpp @@ -56,7 +56,7 @@ void DallasTemperatureBus::begin() { // Optional explicit device fixtures; runtime owns discovery and conversion. addressCount_ = 0; for (uint8_t index = 0; index < 2; ++index) { - if (NativeHal::temperature[index] == DisconnectedCentiC) continue; + if (!NativeHal::temperatureConfigured[index] && NativeHal::temperature[index] == DisconnectedCentiC) continue; uint8_t *rom = addresses_[addressCount_++]; memset(rom, 0, 8); rom[0] = Ds18b20Family; rom[1] = index + 1; rom[7] = crc8(rom, 7); diff --git a/Project/Ina219Sensor.cpp b/Project/Ina219Sensor.cpp index c529eed70..99b75d6b4 100644 --- a/Project/Ina219Sensor.cpp +++ b/Project/Ina219Sensor.cpp @@ -19,15 +19,25 @@ constexpr uint16_t Calibration32V2A = 4096; } // namespace bool Ina219Sensor::begin() { +#if defined(PCCONTROLLER_NATIVE) + return NativeHal::powerAvailable; +#else i2cBus.beginTransmission(address_); if (i2cBus.endTransmission() != 0) { return false; } return write16(CalibrationRegister, Calibration32V2A) && write16(ConfigRegister, Config32V2A); +#endif } bool Ina219Sensor::read(Ina219Reading &reading) { +#if defined(PCCONTROLLER_NATIVE) + if (!NativeHal::powerAvailable) return false; + reading = {NativeHal::supplyMilliVolts, NativeHal::supplyMilliVolts, + NativeHal::currentMilliAmps, NativeHal::powerMilliWatts}; + return true; +#else // Some compatible devices clear calibration after reset; refreshing it also // matches the established Adafruit 32V/2A measurement sequence. if (!write16(CalibrationRegister, Calibration32V2A)) { @@ -61,6 +71,7 @@ bool Ina219Sensor::read(Ina219Reading &reading) { : (signedCurrent - 5L) / 10L; reading.powerMilliWatts = static_cast(powerRaw) * 2UL; return true; +#endif } bool Ina219Sensor::write16(uint8_t reg, uint16_t value) { diff --git a/Project/Runtime/ProtocolRuntime.inc.h b/Project/Runtime/ProtocolRuntime.inc.h index ab779f2d7..2f566698e 100644 --- a/Project/Runtime/ProtocolRuntime.inc.h +++ b/Project/Runtime/ProtocolRuntime.inc.h @@ -65,6 +65,9 @@ void sendHello(uint8_t sequence) { payload.featureProfile = PCCONTROLLER_FEATURE_PROFILE; payload.buildFeatures = static_cast((1U << 6) | (PCCONTROLLER_ENABLE_BOARD_AUTOMATIONS ? (1U << 5) : 0)); +#if defined(PCCONTROLLER_NATIVE) + payload.buildFeatures |= (1U << 7); // Native HAL: mapped/fixture inputs, not physical sensors. +#endif #if defined(__AVR__) && !defined(__AVR_ATmega2560__) // Copy the two contiguous, host-patchable identity fields in one flash // read. Do not replace these with compile-time constants: the guarded host diff --git a/Tools/Controller/internal/appconfig/native_sensors.go b/Tools/Controller/internal/appconfig/native_sensors.go new file mode 100644 index 000000000..500c7d64a --- /dev/null +++ b/Tools/Controller/internal/appconfig/native_sensors.go @@ -0,0 +1,24 @@ +package appconfig + +// NativeSensorNames applies truthful defaults without changing stored names or +// claiming a CPU/battery source belongs to the physical lighting/audio circuit. +// Profile presentation still takes precedence in ProfileDescriptors. +func NativeSensorNames(names map[string]string) map[string]string { + result := make(map[string]string, len(names)+6) + for key, name := range names { + result[key] = name + } + for key, name := range map[string]string{ + "sensor.supply-voltage": "Native voltage", + "sensor.bus-voltage": "Native voltage", + "sensor.current": "Native current", + "sensor.power": "Native power", + "sensor.temperature-led": "Native temperature 1", + "sensor.temperature-audio": "Native temperature 2", + } { + if result[key] == "" { + result[key] = name + } + } + return result +} diff --git a/Tools/Controller/internal/appconfig/native_sensors_test.go b/Tools/Controller/internal/appconfig/native_sensors_test.go new file mode 100644 index 000000000..a27096aa8 --- /dev/null +++ b/Tools/Controller/internal/appconfig/native_sensors_test.go @@ -0,0 +1,19 @@ +package appconfig + +import "testing" + +func TestNativeSensorNamesPreserveAuthoredNamesAndRoles(t *testing.T) { + original := map[string]string{"sensor.temperature-led": "Host CPU", "relay.4": "Fan"} + names := NativeSensorNames(original) + if len(original) != 2 || names["sensor.temperature-led"] != "Host CPU" || names["sensor.temperature-audio"] != "Native temperature 2" { + t.Fatal("native defaults changed authored names or stored configuration") + } + peripherals, _ := ProfileDescriptors(BoardModeUnconfigured, false, names, map[string]PeripheralPresentation{ + "sensor.temperature-audio": {Name: "Battery temperature"}, + }) + for _, peripheral := range peripherals { + if peripheral.Key == "sensor.temperature-audio" && peripheral.Name != "Battery temperature" { + t.Fatal("profile name must win over native defaults") + } + } +} diff --git a/Tools/Controller/internal/ipcjson/ipc.go b/Tools/Controller/internal/ipcjson/ipc.go index 9edc16090..536802e70 100644 --- a/Tools/Controller/internal/ipcjson/ipc.go +++ b/Tools/Controller/internal/ipcjson/ipc.go @@ -1996,6 +1996,9 @@ func (service *Service) setNetworkPeers(raw json.RawMessage) (networkPeersConfig func (service *Service) browserUISettings() browserUISettings { ui := service.hostConfig().UI + if service.Client.Snapshot().Hello.BuildFeatures&native.BuildFeatureNativeHAL != 0 { + ui.PeripheralNames = appconfig.NativeSensorNames(ui.PeripheralNames) + } peripherals := appconfig.PeripheralDescriptors() controls := appconfig.ControlDescriptors(ui.PeripheralNames) appconfig.ApplyPWMChannelConfig(peripherals, controls, ui) @@ -2054,6 +2057,9 @@ func normalizePeripheralNames(names map[string]string) (map[string]string, error func (service *Service) peripheralSettings() peripheralSettings { config := service.hostConfig() names := config.UI.PeripheralNames + if service.Client.Snapshot().Hello.BuildFeatures&native.BuildFeatureNativeHAL != 0 { + names = appconfig.NativeSensorNames(names) + } profileDescriptor, profile := service.activeBoardProfile() peripherals, controls := appconfig.ProfileDescriptors(profileDescriptor.Mode, profile.ExposeRawRelays, names, profile.Presentation) appconfig.ApplyPWMChannelConfig(peripherals, controls, config.UI) diff --git a/Tools/Controller/internal/native/responses.go b/Tools/Controller/internal/native/responses.go index 28af04d88..0cc5ef8a6 100644 --- a/Tools/Controller/internal/native/responses.go +++ b/Tools/Controller/internal/native/responses.go @@ -31,6 +31,9 @@ const BuildFeatureBoardAutomation byte = 1 << 5 // The high bit of MENU_SET_PAGE's page byte navigates without updating EEPROM. const BuildFeatureVolatileMenu byte = 1 << 6 +// Native firmware inputs are explicit mappings/fixtures, not physical probes. +const BuildFeatureNativeHAL byte = 1 << 7 + // HELLO capability bits are the authoritative guard for optional operations. const ( CapabilityINA219 uint32 = 1 << 0 diff --git a/Tools/VirtualBoard/CMakeLists.txt b/Tools/VirtualBoard/CMakeLists.txt index 1802077da..7ee142af9 100644 --- a/Tools/VirtualBoard/CMakeLists.txt +++ b/Tools/VirtualBoard/CMakeLists.txt @@ -65,6 +65,7 @@ file(GENERATE OUTPUT "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/firmware-adapters.sha256 add_library(native_firmware_objects OBJECT src/native_hal.cpp + src/sensor_sources.cpp src/native_firmware.cpp ../../BoardSupportSources.cpp ../../ControllerDomainSources.cpp @@ -293,7 +294,7 @@ if(BUILD_TESTING) if(PCCONTROLLER_NODE) add_test(NAME firmware_transport_integration COMMAND "${PCCONTROLLER_NODE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/transport_integration.test.mjs" $) - set_tests_properties(firmware_transport_integration PROPERTIES TIMEOUT 45) + set_tests_properties(firmware_transport_integration PROPERTIES TIMEOUT 60) endif() # Compile the production AVR framing implementation against a minimal serial diff --git a/Tools/VirtualBoard/README.md b/Tools/VirtualBoard/README.md index 653eb6e72..1c5121948 100644 --- a/Tools/VirtualBoard/README.md +++ b/Tools/VirtualBoard/README.md @@ -88,10 +88,66 @@ explicit process-management artifact useful for tracing Windows process reset. Native relay/PWM/strip/display/buzzer values reflect the running shared drivers. There is no electrical waveform, physical motion or AVR interrupt-fidelity claim. INA219 and DS18B20 values are unavailable by default, not synthetic 12 V/25 C. -CPU temperature is **not** mislabeled as LED/BT temperature. A future commissioned +CPU temperature is **not** mislabeled as LED/BT temperature. Native HELLO feature +`0x80` marks mapped/fixture inputs; the host supplies renamable Native sensor +defaults without overwriting operator/profile titles. A future commissioned Linux/Raspberry Pi GPIO/I2C/sensor adapter can use this same production core. The existing Linux ARM64 build is the no-hardware/demo adapter, not Pi wiring proof. +### Pealayer development without hardware + +Build the native profile and current controller once, then run: + +```sh +node Tools/VirtualBoard/dev-host.mjs +``` + +The launcher runs the shared profile-2 firmware in RAM on loopback `18765` and +an isolated, headless coordinator on `18987`. In Pealayer select hardware endpoint +`pccontroller://127.0.0.1:18987`. Its initial cinema profile advertises both seat +controls and user outputs for authoring; existing profile names, effects and +folders survive restart. Native settings/front-panel caches are read once so a +controller GUI is not required. The normal Pealayer transport, timeline, +recording, macro and safety paths are used, not a second mock engine. + +Use `--board FILE` / `--controller FILE` for packaged binaries. The launcher +checks both ports and a directory-ownership marker, refuses unowned existing +configuration, and does not read or replace production settings. LAN discovery, +keyboard integration, measurement history and buzzer mirroring start disabled. +Ctrl+C stops only its children. The native board creates no EEPROM/log file; +explicit authoring/profile saves still persist in the isolated development root. +The launcher does not start/update Pealayer or change its existing authoring state. + +### Explicit host sensor mappings + +Native sensor paths are read-only scalar sources. Map known Linux thermal/hwmon +temperature files (milli-Celsius), or power_supply voltage/current files +(micro-volts/micro-amps), for example: + +```sh +node Tools/VirtualBoard/dev-host.mjs \ + --sensor-temp0 /sys/class/hwmon/hwmon1/temp1_input +``` + +Inspect that device's `name`/`type` first; hwmon numbering is not a stable sensor +identity. Rename the advertised sensor to its actual source (e.g. Host CPU). +`--sensor-temp1 FILE` maps the second role without sliding it into the first. +`--sensor-voltage FILE --sensor-current FILE` must be supplied together: missing +current is not zero. Both native voltage slots reflect that same mapped voltage, +and power is derived from signed voltage/current; they are not independent INA219 +electrical measurements. No bus at address 0x40 is fabricated. The same options +work directly on `virtual_board`, including stdio mode. + +Files are parsed strictly with a 64-byte bound and sampled at most once per second, +then production conversion/smoothing/validity policies run normally. Missing, +malformed or out-of-range input becomes unavailable and recovers on later valid +input. A mapped temperature can trigger the shared HOT safety policy: select the +source deliberately. These are host readings, not temperatures of absent LED/BT +peripherals. Windows does not have a universally available standard CPU/rail +sensor provider; keep unavailable values absent or explicitly supply a trusted +sensor-export file in these units. No WMI polling subprocess or invented reading +is added to the firmware loop. + With interactive stdin enabled, `help` lists bounded, explicitly named fixtures: ```text diff --git a/Tools/VirtualBoard/dev-host.mjs b/Tools/VirtualBoard/dev-host.mjs new file mode 100644 index 000000000..75b55d8f5 --- /dev/null +++ b/Tools/VirtualBoard/dev-host.mjs @@ -0,0 +1,109 @@ +// Shared-firmware development session. Never reads production configuration, +// discovers serial ports, flashes a device, or replaces a running coordinator. +import { spawn } from 'node:child_process' +import { createConnection, createServer } from 'node:net' +import { mkdir, readFile, writeFile, access } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createInterface } from 'node:readline' + +const here = dirname(fileURLToPath(import.meta.url)) +const suffix = process.platform === 'win32' ? '.exe' : '' +const options = { board: join(here, '.build/release/bin/virtual_board' + suffix), controller: join(here, '../Controller/bin/controller' + suffix), + 'board-port': '18765', 'host-port': '18987', 'state-root': process.platform === 'win32' + ? join(process.env.LOCALAPPDATA, 'Programs/PCController/development/virtual-board') + : join(homedir(), '.local/share/pccontroller/virtual-development'), 'sensor-temp0':'', 'sensor-temp1':'', 'sensor-voltage':'', 'sensor-current':'' } +for (let i=2; i 65535) throw new Error('development ports must be 1024..65535') +if (boardPort === hostPort) throw new Error('board and coordinator ports must differ') +await access(options.board); await access(options.controller) +for (const port of [boardPort, hostPort]) { + const listener = createServer() + await new Promise((ok, fail) => { listener.once('error', fail); listener.listen(port, '127.0.0.1', ok) }) + await new Promise(ok => listener.close(ok)) +} +const state = resolve(options['state-root']), configPath = join(state, 'config.json') +await mkdir(state, { recursive: true }) +const marker = join(state, 'virtual-development.json') +let existingConfig = false +try { await access(configPath); existingConfig = true } catch (error) { if (error.code !== 'ENOENT') throw error } +try { + if (JSON.parse(await readFile(marker, 'utf8')).owner !== 'pccontroller-virtual-development') throw new Error('wrong development directory owner') +} catch (error) { + if (error.code !== 'ENOENT' || existingConfig) throw new Error('refusing unowned/existing configuration directory') + await writeFile(marker, JSON.stringify({owner:'pccontroller-virtual-development'})+'\n', {flag:'wx'}) +} +try { await access(configPath) } catch (error) { + if (error.code !== 'ENOENT') throw error + // Only seed a new explicitly isolated host profile. Existing authored effects, + // names and folders are retained on every subsequent run. + await writeFile(configPath, JSON.stringify({ schema: 2, connection: { port: `tcp://127.0.0.1:${boardPort}`, reset_on_reconnect: false }, + ipc: { listen: `127.0.0.1:${hostPort}`, allow_remote: false }, ui: { history_hours: 0 }, + integrations: { discovery: { mdns_enabled:false, dns_sd_enabled:false, ssdp_enabled:false, upnp_enabled:false, ws_discovery_enabled:false, broadcast_enabled:false, netbios_enabled:false }, + buzzer_mirror: { enabled:false }, keyboard: { enabled:false } } }, null, 2) + '\n', { flag: 'wx' }) +} +const config = JSON.parse(await readFile(configPath, 'utf8')) +if (config.ipc?.allow_remote || config.integrations?.websocket_clients?.length || config.integrations?.peers?.length) + throw new Error('dev launcher refuses a remotely connected/shared configuration') +const children = [] +let socket, shuttingDown = false +function stop() { + if (shuttingDown) return + shuttingDown = true; socket?.destroy() + for (const child of children) if (child.exitCode === null && !child.killed) child.kill() +} +process.on('SIGINT', stop); process.on('SIGTERM', stop) +function start(binary, args) { + const child = spawn(binary, args, { windowsHide: true, stdio: ['ignore', 'inherit', 'inherit'] }) + children.push(child) + child.on('error', error => { console.error(error.message); process.exitCode = 1; stop() }) + child.on('exit', code => { if (!shuttingDown) { process.exitCode = code || 1; stop() } }) + return child +} +async function connect(port) { + const deadline = Date.now() + 10000 + while (Date.now() < deadline && !shuttingDown) { + const connection = createConnection({ host:'127.0.0.1', port }) + try { await new Promise((ok, fail) => { connection.once('connect', ok); connection.once('error', fail) }); return connection } + catch { connection.destroy(); await new Promise(ok => setTimeout(ok, 50)) } + } + throw new Error('development process did not become ready') +} +try { + const sensorArgs = Object.entries(options).filter(([key,value]) => key.startsWith('sensor-') && value).flatMap(([key,value]) => ['--'+key,value]) + start(options.board, ['--bind','127.0.0.1','--port',String(boardPort),'--memory','--no-stdin','--quiet',...sensorArgs]) + const ready = await connect(boardPort); ready.destroy() + start(options.controller, ['--config',configPath,'ipc','serve','--listen',`127.0.0.1:${hostPort}`,'--port',`tcp://127.0.0.1:${boardPort}`,'--no-connect']) + socket = await connect(hostPort) + const pending = new Map(); let nextId = 1 + const lines = createInterface({ input:socket }) + lines.on('line', line => { + let message + try { message = JSON.parse(line) } catch { console.error('malformed coordinator response'); process.exitCode = 1; stop(); return } + const request = pending.get(message.id) + if (!request) return + pending.delete(message.id); clearTimeout(request.timeout) + if (message.error) request.fail(new Error(message.error.message)); else request.ok(message.result) + }) + socket.on('error', error => { for (const request of pending.values()) request.fail(error); stop() }) + const call = (method, params={}) => new Promise((ok, fail) => { + const id = nextId++, timeout = setTimeout(() => { pending.delete(id); fail(new Error(method + ' timed out')) }, 10000) + pending.set(id,{ok,fail,timeout}); socket.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n') + }) + await call('controller.app.instance.report',{id:'virtual-development',surface:'bridge',page:'development',state:'active',lease_seconds:45}) + const snapshot = await call('controller.connect',{port:`tcp://127.0.0.1:${boardPort}`}) + if (!snapshot.connected || !(snapshot.hello.build_features & 128)) throw new Error('Refusing a non-native/obsolete firmware target') + await call('controller.command.execute',{command:'settings'}) + await call('controller.front_panel') + const profile = await call('controller.board_profile.get') + if (!profile.configured) await call('controller.board_profile.update',{key:'development',mode:'cinema-seat-motion',expected_revision:profile.revision}) + socket.destroy(); lines.close() + console.log(`Pealayer hardware endpoint: pccontroller://127.0.0.1:${hostPort}\nIsolated authoring profile: ${state}\nRAM-only shared firmware; Ctrl+C stops only these development processes.`) + await Promise.all(children.map(child => child.exitCode !== null ? Promise.resolve() : new Promise(ok => child.once('exit',ok)))) +} catch (error) { console.error(error.message); process.exitCode = 1; stop() } diff --git a/Tools/VirtualBoard/include/virtual_board/sensor_sources.hpp b/Tools/VirtualBoard/include/virtual_board/sensor_sources.hpp new file mode 100644 index 000000000..b7313f7df --- /dev/null +++ b/Tools/VirtualBoard/include/virtual_board/sensor_sources.hpp @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +namespace NativeHal { +// Explicit read-only mappings, never automatic CPU -> lighting assignment. +// Linux hwmon/thermal use milli-Celsius; power_supply uses micro-V/A. +struct SensorSources { + std::array temperature; + std::string voltage, current; +}; +void configureSensorSources(const SensorSources &sources); +bool refreshSensorSources(uint64_t nowUs, bool force = false); +// Bounded strict parser shared by adapters/tests. Missing data remains unknown. +bool readSensorInteger(const std::string &path, int64_t &value); +} diff --git a/Tools/VirtualBoard/platform/Arduino.h b/Tools/VirtualBoard/platform/Arduino.h index 1fed31148..631cc1287 100644 --- a/Tools/VirtualBoard/platform/Arduino.h +++ b/Tools/VirtualBoard/platform/Arduino.h @@ -42,6 +42,9 @@ inline uint8_t shiftInput=0xFF, shiftOutput=0xFF; inline bool tm1637Present=true, pwmPresent=true; inline uint16_t toneFrequency=0; inline std::array temperature{INT16_MIN,INT16_MIN}; +inline std::array temperatureConfigured{}; +inline bool powerAvailable=false; +inline int32_t supplyMilliVolts=0, currentMilliAmps=0, powerMilliWatts=0; inline std::vector strip; struct RadioPacket { uint32_t code; uint16_t pulse; uint8_t bits, protocol; }; inline std::deque radioRx; diff --git a/Tools/VirtualBoard/src/main.cpp b/Tools/VirtualBoard/src/main.cpp index 591cdbfec..5a97df2b9 100644 --- a/Tools/VirtualBoard/src/main.cpp +++ b/Tools/VirtualBoard/src/main.cpp @@ -2,6 +2,7 @@ #define _POSIX_C_SOURCE 200809L #endif #include "virtual_board/tcp_server.hpp" +#include "virtual_board/sensor_sources.hpp" #include #include #include @@ -55,6 +56,7 @@ int main(int argc,char **argv) { std::filesystem::path eeprom; std::filesystem::path pidFile; bool stdinEnabled=true,stdioMode=false,memory=false; + NativeHal::SensorSources sensorSources; for(int i=1;i std::string { if(++i==argc) throw std::invalid_argument(arg+" requires a value"); return argv[i]; }; @@ -67,10 +69,15 @@ int main(int argc,char **argv) { else if(arg=="--quiet") options.quiet=true; else if(arg=="--log-pwm") options.logPwm=true; else if(arg=="--stdio") stdioMode=true; - else if(arg=="--help" || arg=="-h") { std::cout << "PCController production firmware, OS-native target\nvirtual_board [--bind IPv4] [--port 8765] [--eeprom FILE | --memory] [--no-stdin] [--quiet] [--log-pwm] [--stdio] [--pid-file FILE]\nTCP mode: relay ON:n/OFF:n on stdout; opt-in PWM logging. Console inputs are explicit fixtures.\nStdio mode: binary protocol only on stdin/stdout, diagnostics on stderr.\n"; return 0; } + else if(arg=="--sensor-temp0") sensorSources.temperature[0]=value(); + else if(arg=="--sensor-temp1") sensorSources.temperature[1]=value(); + else if(arg=="--sensor-voltage") sensorSources.voltage=value(); + else if(arg=="--sensor-current") sensorSources.current=value(); + else if(arg=="--help" || arg=="-h") { std::cout << "PCController production firmware, OS-native target\nvirtual_board [--bind IPv4] [--port 8765] [--eeprom FILE | --memory] [--no-stdin] [--quiet] [--log-pwm] [--stdio] [--pid-file FILE]\nOptional read-only host mappings: --sensor-temp0 FILE --sensor-temp1 FILE (milli-Celsius); --sensor-voltage FILE --sensor-current FILE (micro-V/A, both required).\nTCP mode: relay ON:n/OFF:n on stdout; opt-in PWM logging. Console inputs are explicit fixtures.\nStdio mode: binary protocol only on stdin/stdout, diagnostics on stderr.\n"; return 0; } else throw std::invalid_argument("unknown option: "+arg); } if(memory && !eeprom.empty()) throw std::invalid_argument("--memory and --eeprom are exclusive"); + NativeHal::configureSensorSources(sensorSources); if(!memory && eeprom.empty()) eeprom="native-firmware-eeprom-"+std::to_string(options.port)+".bin"; auto board=std::make_shared(eeprom); if(!pidFile.empty()) { diff --git a/Tools/VirtualBoard/src/native_firmware.cpp b/Tools/VirtualBoard/src/native_firmware.cpp index b77246232..f95330cd7 100644 --- a/Tools/VirtualBoard/src/native_firmware.cpp +++ b/Tools/VirtualBoard/src/native_firmware.cpp @@ -2,6 +2,7 @@ #include "../../../PCController.ino" #include #include "virtual_board/virtual_board.hpp" +#include "virtual_board/sensor_sources.hpp" #include #include #include @@ -11,6 +12,7 @@ namespace { std::atomic claimed{false}; } VirtualBoard::VirtualBoard(const std::filesystem::path &eeprom,bool deterministic) { if(claimed.exchange(true)) throw std::runtime_error("one firmware instance per process; use an isolated process/EEPROM for each board"); NativeHal::configureClock(deterministic); + NativeHal::refreshSensorSources(0, true); EEPROM.open(eeprom); setup(); EEPROM.flush(); @@ -25,6 +27,13 @@ std::vector VirtualBoard::tick(uint64_t advanceUs) { std::lock_guard lock(mutex_); if(!resetRequested_) { NativeHal::advance(advanceUs); + if (NativeHal::refreshSensorSources(NativeHal::clockMicros())) { + ina219Available = NativeHal::powerAvailable; + if (!ina219Available) { + sensors.supplyMilliVolts = sensors.busMilliVolts = INVALID_I32; + sensors.currentMilliAmps = sensors.powerMilliWatts = INVALID_I32; + } + } try { loop(); } catch(const NativeHal::ResetRequested &) { resetRequested_=true; EEPROM.flush(); } // Coalesce native image writes; EEPROM.update still owns wear reduction. static uint32_t lastFlush=0; @@ -64,7 +73,7 @@ OutputSnapshot VirtualBoard::outputSnapshot() const { std::string VirtualBoard::describe() const { const auto s=outputSnapshot(); std::ostringstream out; - out << "production firmware; relays=" << unsigned(s.relayMask) << "; TM1637=" << (s.tm1637Detected ? "present" : "absent") << "; buzzer=" << s.buzzerHz << " Hz; strip=" << s.strip.size()/3 << " pixels; INA219=unavailable; temperatures=unavailable unless explicitly injected"; + out << "production firmware; relays=" << unsigned(s.relayMask) << "; TM1637=" << (s.tm1637Detected ? "present" : "absent") << "; buzzer=" << s.buzzerHz << " Hz; strip=" << s.strip.size()/3 << " pixels; sensors=native inputs (explicit mappings/fixtures only)"; return out.str(); } ConsoleResult VirtualBoard::console(const std::string &line) { diff --git a/Tools/VirtualBoard/src/sensor_sources.cpp b/Tools/VirtualBoard/src/sensor_sources.cpp new file mode 100644 index 000000000..3d992bd08 --- /dev/null +++ b/Tools/VirtualBoard/src/sensor_sources.cpp @@ -0,0 +1,64 @@ +#include "virtual_board/sensor_sources.hpp" +#include +#include +#include +#include +#include + +namespace NativeHal { +namespace { +SensorSources sources; +uint64_t lastReadUs = 0; +bool readOnce = false; +} +bool readSensorInteger(const std::string &path, int64_t &value) { + if (path.empty()) return false; + std::error_code error; + if (!std::filesystem::is_regular_file(path, error) || error) return false; + std::ifstream input(path, std::ios::binary); + char buffer[64]; + input.read(buffer, sizeof(buffer)); + const auto count = input.gcount(); + if (input.bad() || count <= 0 || count == sizeof(buffer)) return false; + const char *first = buffer, *last = buffer + count; + auto whitespace = [](char c) { return c==' ' || c=='\t' || c=='\r' || c=='\n'; }; + while (first != last && whitespace(*first)) ++first; + while (first != last && whitespace(last[-1])) --last; + int64_t parsed = 0; + const auto result = std::from_chars(first, last, parsed); + if (result.ec != std::errc{} || result.ptr != last) return false; + value = parsed; + return true; +} +void configureSensorSources(const SensorSources &value) { + if (value.voltage.empty() != value.current.empty()) + throw std::invalid_argument("native power mapping requires both --sensor-voltage and --sensor-current; unknown current is not zero"); + sources = value; + readOnce = false; + // Reserve role zero when only role one is mapped, so discovery cannot move + // the second named source into the first role after unplug/recovery. + temperatureConfigured[0] = !sources.temperature[0].empty() || !sources.temperature[1].empty(); + temperatureConfigured[1] = !sources.temperature[1].empty(); +} +bool refreshSensorSources(uint64_t nowUs, bool force) { + if (!force && readOnce && nowUs - lastReadUs < 1000000) return false; + readOnce = true; lastReadUs = nowUs; + for (size_t i=0; i<2; ++i) { + if (sources.temperature[i].empty()) continue; + int64_t milliC = 0; + temperature[i] = readSensorInteger(sources.temperature[i], milliC) && milliC >= -55000 && milliC <= 125000 + ? static_cast(milliC / 10) : INT16_MIN; + } + if (!sources.voltage.empty()) { + int64_t microV = 0, microA = 0; + powerAvailable = readSensorInteger(sources.voltage, microV) && readSensorInteger(sources.current, microA) + && microV >= 0 && microV <= 100000000 && microA >= -100000000 && microA <= 100000000; + if (powerAvailable) { + supplyMilliVolts = static_cast(microV / 1000); + currentMilliAmps = static_cast(microA / 1000); + powerMilliWatts = static_cast(static_cast(supplyMilliVolts) * currentMilliAmps / 1000); + } + } + return true; +} +} diff --git a/Tools/VirtualBoard/tests/transport_integration.test.mjs b/Tools/VirtualBoard/tests/transport_integration.test.mjs index 421696db4..827b66c75 100644 --- a/Tools/VirtualBoard/tests/transport_integration.test.mjs +++ b/Tools/VirtualBoard/tests/transport_integration.test.mjs @@ -1,6 +1,6 @@ import { spawn, execFileSync } from 'node:child_process' import { createConnection, createServer } from 'node:net' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import assert from 'node:assert/strict' @@ -69,6 +69,41 @@ try { assert.equal(status.payload.readInt16LE(20), -32768) stdio.stdin.end(); await new Promise(resolve => stdio.once('exit', resolve)); assert.equal(stdio.exitCode, 0, stdio.diagnostics()) + // Explicit host mappings still traverse production sampling, smoothing, + // discovery, status validity and recovery; not a second telemetry runtime. + const tempPath = join(state, 'host-temp'), voltagePath = join(state, 'host-voltage'), currentPath = join(state, 'host-current') + await writeFile(tempPath, '42000\n'); await writeFile(voltagePath, '12000000\n'); await writeFile(currentPath, '250000\n') + const mapped = start(['--stdio', '--memory', '--sensor-temp1', tempPath, '--sensor-voltage', voltagePath, '--sensor-current', currentPath]) + const mappedFrames = channel(mapped.stdout) + mapped.stdin.write(encode(1, 43)) + assert((await mappedFrames(f => f.opcode === 129 && f.sequence === 43)).payload[15] & 128, 'native input origin must be advertised') + let sensorSeq = 60 + const mappedStatus = async predicate => { + const until = Date.now() + 6000 + while (Date.now() < until) { + const seq = sensorSeq++ % 255 + mapped.stdin.write(encode(2, seq)) + const { payload } = await mappedFrames(f => f.opcode === 144 && f.sequence === seq) + if (predicate(payload)) return payload + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error('mapped sensor state never became authoritative') + } + const liveMapped = await mappedStatus(p => (p.readUInt16LE(24) & 9) === 9) + assert.equal(liveMapped.readInt32LE(4), 12000); assert.equal(liveMapped.readInt32LE(12), 250); assert.equal(liveMapped.readInt32LE(16), 3000) + assert.equal(liveMapped.readInt16LE(20), -32768, 'source one must not migrate into role zero') + assert.equal(liveMapped.readInt16LE(22), 4200) + await writeFile(currentPath, '250000junk\n'); await writeFile(tempPath, '999999999999999999999999999999999999999\n') + const missingMapped = await mappedStatus(p => (p.readUInt16LE(24) & 9) === 0) + assert.equal(missingMapped.readInt32LE(4), -2147483648, 'invalid/missing source must clear stale power') + assert.equal(missingMapped.readInt16LE(22), -32768, 'invalid/missing source must clear stale temperature') + await writeFile(currentPath, '-100000\n'); await writeFile(tempPath, '-10000\n') + const recoveredMapped = await mappedStatus(p => (p.readUInt16LE(24) & 9) === 9) + assert.equal(recoveredMapped.readInt32LE(12), -100); assert.equal(recoveredMapped.readInt32LE(16), -1200) + assert.equal(recoveredMapped.readInt16LE(22), -1000) + mapped.stdin.end(); await new Promise(resolve => mapped.once('exit', resolve)); assert.equal(mapped.exitCode, 0, mapped.diagnostics()) + assert(!await readFile(join(state, 'native-firmware-eeprom-8765.bin')).then(() => true, () => false), 'RAM-only development must not create EEPROM files') + const port = await freePort(), eeprom = join(state, 'board.bin') // Restart must resolve the loaded OS image, not this untrusted argv[0]. let child = start(['--port', String(port), '--eeprom', eeprom, '--pid-file', pidFile, '--no-stdin'], join(state, 'not-a-firmware-executable')) From 9435492b6f8e543f338a4cc347f64f67723f6d54 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Mon, 12 Oct 2026 23:53:36 +0330 Subject: [PATCH 04/72] test(virtual): verify live host sensor source and honor shared environment policy --- Tools/VirtualBoard/dev-host.mjs | 8 ++++++-- .../tests/transport_integration.test.mjs | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Tools/VirtualBoard/dev-host.mjs b/Tools/VirtualBoard/dev-host.mjs index 75b55d8f5..f4186bfaa 100644 --- a/Tools/VirtualBoard/dev-host.mjs +++ b/Tools/VirtualBoard/dev-host.mjs @@ -7,6 +7,9 @@ import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { createInterface } from 'node:readline' +import { loadProjectEnv } from '../Build/env.mjs' + +loadProjectEnv() const here = dirname(fileURLToPath(import.meta.url)) const suffix = process.platform === 'win32' ? '.exe' : '' @@ -46,10 +49,11 @@ try { await access(configPath) } catch (error) { await writeFile(configPath, JSON.stringify({ schema: 2, connection: { port: `tcp://127.0.0.1:${boardPort}`, reset_on_reconnect: false }, ipc: { listen: `127.0.0.1:${hostPort}`, allow_remote: false }, ui: { history_hours: 0 }, integrations: { discovery: { mdns_enabled:false, dns_sd_enabled:false, ssdp_enabled:false, upnp_enabled:false, ws_discovery_enabled:false, broadcast_enabled:false, netbios_enabled:false }, - buzzer_mirror: { enabled:false }, keyboard: { enabled:false } } }, null, 2) + '\n', { flag: 'wx' }) + buzzer_mirror: { enabled:false }, keyboard_control: { enabled:false } } }, null, 2) + '\n', { flag: 'wx' }) } const config = JSON.parse(await readFile(configPath, 'utf8')) -if (config.ipc?.allow_remote || config.integrations?.websocket_clients?.length || config.integrations?.peers?.length) +if (config.ipc?.allow_remote || config.integrations?.websocket_clients?.length + || config.integrations?.board_mirror?.groups?.length || config.integrations?.board_mirror?.receive_from) throw new Error('dev launcher refuses a remotely connected/shared configuration') const children = [] let socket, shuttingDown = false diff --git a/Tools/VirtualBoard/tests/transport_integration.test.mjs b/Tools/VirtualBoard/tests/transport_integration.test.mjs index 827b66c75..bcc4caa36 100644 --- a/Tools/VirtualBoard/tests/transport_integration.test.mjs +++ b/Tools/VirtualBoard/tests/transport_integration.test.mjs @@ -104,6 +104,26 @@ try { mapped.stdin.end(); await new Promise(resolve => mapped.once('exit', resolve)); assert.equal(mapped.exitCode, 0, mapped.diagnostics()) assert(!await readFile(join(state, 'native-firmware-eeprom-8765.bin')).then(() => true, () => false), 'RAM-only development must not create EEPROM files') + // Optional live Linux scalar-source acceptance, still on our own native + // process. Never actuates or connects to an existing physical controller. + if (process.env.PCCONTROLLER_NATIVE_TEMPERATURE_SOURCE) { + const source = process.env.PCCONTROLLER_NATIVE_TEMPERATURE_SOURCE + const actual = Number((await readFile(source, 'utf8')).trim()) + assert(Number.isInteger(actual) && actual >= -55000 && actual <= 125000) + const hostMapped = start(['--stdio','--memory','--sensor-temp0',source]) + const hostFrames = channel(hostMapped.stdout) + let observed + for (let i=0; i<60; ++i) { + hostMapped.stdin.write(encode(2, 44)) + const { payload } = await hostFrames(f => f.opcode === 144 && f.sequence === 44) + if (payload.readUInt16LE(24) & 4) { observed = payload.readInt16LE(20); break } + await new Promise(resolve => setTimeout(resolve, 100)) + } + assert(observed !== undefined && observed >= -5500 && observed <= 12500, 'live source must be observed through production telemetry') + console.log(`live host-mapped native temperature: ${observed / 100} C`) + hostMapped.stdin.end(); await new Promise(resolve => hostMapped.once('exit', resolve)); assert.equal(hostMapped.exitCode, 0, hostMapped.diagnostics()) + } + const port = await freePort(), eeprom = join(state, 'board.bin') // Restart must resolve the loaded OS image, not this untrusted argv[0]. let child = start(['--port', String(port), '--eeprom', eeprom, '--pid-file', pidFile, '--no-stdin'], join(state, 'not-a-firmware-executable')) From 710b2d6e4f86e9cf76f7579e35913d0bb61133a2 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 00:00:25 +0330 Subject: [PATCH 05/72] fix(virtual): make development profile ownership and seeding descriptor-safe --- Tools/VirtualBoard/CMakeLists.txt | 3 ++ Tools/VirtualBoard/dev-host.mjs | 49 +++++++++++------ Tools/VirtualBoard/tests/dev_host.test.mjs | 61 ++++++++++++++++++++++ 3 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 Tools/VirtualBoard/tests/dev_host.test.mjs diff --git a/Tools/VirtualBoard/CMakeLists.txt b/Tools/VirtualBoard/CMakeLists.txt index 7ee142af9..efd8f8508 100644 --- a/Tools/VirtualBoard/CMakeLists.txt +++ b/Tools/VirtualBoard/CMakeLists.txt @@ -295,6 +295,9 @@ if(BUILD_TESTING) add_test(NAME firmware_transport_integration COMMAND "${PCCONTROLLER_NODE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/transport_integration.test.mjs" $) set_tests_properties(firmware_transport_integration PROPERTIES TIMEOUT 60) + add_test(NAME firmware_dev_host_guards COMMAND "${PCCONTROLLER_NODE}" + --test "${CMAKE_CURRENT_SOURCE_DIR}/tests/dev_host.test.mjs") + set_tests_properties(firmware_dev_host_guards PROPERTIES TIMEOUT 45) endif() # Compile the production AVR framing implementation against a minimal serial diff --git a/Tools/VirtualBoard/dev-host.mjs b/Tools/VirtualBoard/dev-host.mjs index f4186bfaa..b4a7c6fa3 100644 --- a/Tools/VirtualBoard/dev-host.mjs +++ b/Tools/VirtualBoard/dev-host.mjs @@ -2,7 +2,8 @@ // discovers serial ports, flashes a device, or replaces a running coordinator. import { spawn } from 'node:child_process' import { createConnection, createServer } from 'node:net' -import { mkdir, readFile, writeFile, access } from 'node:fs/promises' +import { mkdir, open, access } from 'node:fs/promises' +import { constants } from 'node:fs' import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -34,24 +35,42 @@ for (const port of [boardPort, hostPort]) { const state = resolve(options['state-root']), configPath = join(state, 'config.json') await mkdir(state, { recursive: true }) const marker = join(state, 'virtual-development.json') -let existingConfig = false -try { await access(configPath); existingConfig = true } catch (error) { if (error.code !== 'ENOENT') throw error } +const seed = JSON.stringify({ schema: 2, connection: { port: `tcp://127.0.0.1:${boardPort}`, reset_on_reconnect: false }, + ipc: { listen: `127.0.0.1:${hostPort}`, allow_remote: false }, ui: { history_hours: 0 }, + integrations: { discovery: { mdns_enabled:false, dns_sd_enabled:false, ssdp_enabled:false, upnp_enabled:false, ws_discovery_enabled:false, broadcast_enabled:false, netbios_enabled:false }, + buzzer_mirror: { enabled:false }, keyboard_control: { enabled:false } } }, null, 2) + '\n' +const readFlags = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) +const createFlags = constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0) +async function readJson(handle) { + const stat = await handle.stat() + if (!stat.isFile() || stat.size > 1048576) throw new Error('development metadata must be a bounded regular file') + return JSON.parse(await handle.readFile('utf8')) +} +let owned = false try { - if (JSON.parse(await readFile(marker, 'utf8')).owner !== 'pccontroller-virtual-development') throw new Error('wrong development directory owner') + const handle = await open(marker, readFlags) + try { owned = (await readJson(handle)).owner === 'pccontroller-virtual-development' } finally { await handle.close() } + if (!owned) throw new Error('wrong development directory owner') } catch (error) { - if (error.code !== 'ENOENT' || existingConfig) throw new Error('refusing unowned/existing configuration directory') - await writeFile(marker, JSON.stringify({owner:'pccontroller-virtual-development'})+'\n', {flag:'wx'}) -} -try { await access(configPath) } catch (error) { if (error.code !== 'ENOENT') throw error - // Only seed a new explicitly isolated host profile. Existing authored effects, - // names and folders are retained on every subsequent run. - await writeFile(configPath, JSON.stringify({ schema: 2, connection: { port: `tcp://127.0.0.1:${boardPort}`, reset_on_reconnect: false }, - ipc: { listen: `127.0.0.1:${hostPort}`, allow_remote: false }, ui: { history_hours: 0 }, - integrations: { discovery: { mdns_enabled:false, dns_sd_enabled:false, ssdp_enabled:false, upnp_enabled:false, ws_discovery_enabled:false, broadcast_enabled:false, netbios_enabled:false }, - buzzer_mirror: { enabled:false }, keyboard_control: { enabled:false } } }, null, 2) + '\n', { flag: 'wx' }) } -const config = JSON.parse(await readFile(configPath, 'utf8')) +let config +let handle +let created = false +if (owned) { + try { handle = await open(configPath, readFlags) } catch (error) { if (error.code !== 'ENOENT') throw error } +} +// An unowned directory must win exclusive creation, never probe-then-overwrite +// an existing configuration. Read/check/write against the opened descriptors. +if (!handle) { handle = await open(configPath, createFlags, 0o600); created = true } +try { + if (created) { await handle.writeFile(seed); config = JSON.parse(seed) } + else config = await readJson(handle) + if (!owned) { + const ownerHandle = await open(marker, createFlags, 0o600) + try { await ownerHandle.writeFile(JSON.stringify({owner:'pccontroller-virtual-development'})+'\n') } finally { await ownerHandle.close() } + } +} finally { await handle.close() } if (config.ipc?.allow_remote || config.integrations?.websocket_clients?.length || config.integrations?.board_mirror?.groups?.length || config.integrations?.board_mirror?.receive_from) throw new Error('dev launcher refuses a remotely connected/shared configuration') diff --git a/Tools/VirtualBoard/tests/dev_host.test.mjs b/Tools/VirtualBoard/tests/dev_host.test.mjs new file mode 100644 index 000000000..2d71c7d68 --- /dev/null +++ b/Tools/VirtualBoard/tests/dev_host.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'node:net' +import { execFile } from 'node:child_process' + +const launcher = fileURLToPath(new URL('../dev-host.mjs', import.meta.url)) +async function port() { + const listener = createServer() + await new Promise(ok => listener.listen(0, '127.0.0.1', ok)) + const value = listener.address().port + await new Promise(ok => listener.close(ok)) + return value +} +async function run(state) { + let board = await port(), host = await port() + while (board === host) host = await port() + // Node deliberately rejects firmware argv. No board/host/device is started; + // these tests exercise preservation and atomic seeding before readiness. + return await new Promise(ok => execFile(process.execPath, [launcher,'--state-root',state,'--board',process.execPath, + '--controller',process.execPath,'--board-port',String(board),'--host-port',String(host)], + { timeout:15000, windowsHide:true }, (error,stdout,stderr) => ok({error,stdout,stderr}))) +} +test('unowned existing configuration is neither read as a profile nor overwritten', async () => { + const state = await mkdtemp(join(tmpdir(),'pccontroller-dev-ownership-')) + try { + const content = 'private existing state\n' + await writeFile(join(state,'config.json'),content) + const result = await run(state) + assert(result.error); assert.match(result.stderr,/EEXIST/) + assert.equal(await readFile(join(state,'config.json'),'utf8'),content) + await assert.rejects(readFile(join(state,'virtual-development.json')), {code:'ENOENT'}) + } finally { await rm(state,{recursive:true,force:true}) } +}) +test('owned remote configuration is refused without overwriting authoring', async () => { + const state = await mkdtemp(join(tmpdir(),'pccontroller-dev-remote-')) + try { + await writeFile(join(state,'virtual-development.json'),JSON.stringify({owner:'pccontroller-virtual-development'})) + const content = JSON.stringify({ipc:{allow_remote:true},macros:[{name:'keep draft'}]}) + await writeFile(join(state,'config.json'),content) + const result = await run(state) + assert(result.error); assert.match(result.stderr,/remotely connected/) + assert.equal(await readFile(join(state,'config.json'),'utf8'),content) + } finally { await rm(state,{recursive:true,force:true}) } +}) +test('fresh development seed uses bounded RAM-only isolated defaults', async () => { + const state = await mkdtemp(join(tmpdir(),'pccontroller-dev-seed-')) + try { + const result = await run(state) + assert(result.error, 'fake native binary must not become ready') + const config = JSON.parse(await readFile(join(state,'config.json'),'utf8')) + assert.equal(config.integrations.keyboard_control.enabled,false) + assert.equal(config.integrations.buzzer_mirror.enabled,false) + assert.equal(config.ui.history_hours,0) + assert.equal(config.connection.reset_on_reconnect,false) + assert.equal(JSON.parse(await readFile(join(state,'virtual-development.json'),'utf8')).owner,'pccontroller-virtual-development') + } finally { await rm(state,{recursive:true,force:true}) } +}) From dffba35383c2e323284f8d64a713ad0c4f5f240e Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 00:03:35 +0330 Subject: [PATCH 06/72] fix(virtual): use explicit exclusive ownership-marker creation --- Tools/VirtualBoard/dev-host.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/VirtualBoard/dev-host.mjs b/Tools/VirtualBoard/dev-host.mjs index b4a7c6fa3..c69431200 100644 --- a/Tools/VirtualBoard/dev-host.mjs +++ b/Tools/VirtualBoard/dev-host.mjs @@ -67,7 +67,7 @@ try { if (created) { await handle.writeFile(seed); config = JSON.parse(seed) } else config = await readJson(handle) if (!owned) { - const ownerHandle = await open(marker, createFlags, 0o600) + const ownerHandle = await open(marker, 'wx', 0o600) try { await ownerHandle.writeFile(JSON.stringify({owner:'pccontroller-virtual-development'})+'\n') } finally { await ownerHandle.close() } } } finally { await handle.close() } From bd378ae0a04c996a45806f5817cd7159984705a9 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 00:08:58 +0330 Subject: [PATCH 07/72] fix(virtual): retain one ownership descriptor throughout development seeding --- Tools/VirtualBoard/dev-host.mjs | 36 ++++++++++++---------- Tools/VirtualBoard/tests/dev_host.test.mjs | 2 +- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Tools/VirtualBoard/dev-host.mjs b/Tools/VirtualBoard/dev-host.mjs index c69431200..8e57532fd 100644 --- a/Tools/VirtualBoard/dev-host.mjs +++ b/Tools/VirtualBoard/dev-host.mjs @@ -46,31 +46,35 @@ async function readJson(handle) { if (!stat.isFile() || stat.size > 1048576) throw new Error('development metadata must be a bounded regular file') return JSON.parse(await handle.readFile('utf8')) } -let owned = false +let owned = false, ownerHandle, createdOwner = false try { - const handle = await open(marker, readFlags) - try { owned = (await readJson(handle)).owner === 'pccontroller-virtual-development' } finally { await handle.close() } - if (!owned) throw new Error('wrong development directory owner') + // Acquire a new ownership descriptor first, rather than checking the marker + // and reopening its pathname later. Existing metadata is read only. + ownerHandle = await open(marker, 'wx+', 0o600) + createdOwner = true } catch (error) { - if (error.code !== 'ENOENT') throw error + if (error.code !== 'EEXIST') throw error + ownerHandle = await open(marker, readFlags) } let config let handle let created = false -if (owned) { - try { handle = await open(configPath, readFlags) } catch (error) { if (error.code !== 'ENOENT') throw error } -} -// An unowned directory must win exclusive creation, never probe-then-overwrite -// an existing configuration. Read/check/write against the opened descriptors. -if (!handle) { handle = await open(configPath, createFlags, 0o600); created = true } try { + if (!createdOwner) { + owned = (await readJson(ownerHandle)).owner === 'pccontroller-virtual-development' + if (!owned) throw new Error('wrong development directory owner') + } + if (owned) { + try { handle = await open(configPath, readFlags) } catch (error) { if (error.code !== 'ENOENT') throw error } + } + // An unowned directory must win exclusive creation, never probe-then-overwrite + // an existing configuration. A failed seed leaves an empty (invalid) marker: + // later launches still fail closed, without deleting a pathname after close. + if (!handle) { handle = await open(configPath, createFlags, 0o600); created = true } if (created) { await handle.writeFile(seed); config = JSON.parse(seed) } else config = await readJson(handle) - if (!owned) { - const ownerHandle = await open(marker, 'wx', 0o600) - try { await ownerHandle.writeFile(JSON.stringify({owner:'pccontroller-virtual-development'})+'\n') } finally { await ownerHandle.close() } - } -} finally { await handle.close() } + if (createdOwner) await ownerHandle.writeFile(JSON.stringify({owner:'pccontroller-virtual-development'})+'\n') +} finally { await handle?.close(); await ownerHandle.close() } if (config.ipc?.allow_remote || config.integrations?.websocket_clients?.length || config.integrations?.board_mirror?.groups?.length || config.integrations?.board_mirror?.receive_from) throw new Error('dev launcher refuses a remotely connected/shared configuration') diff --git a/Tools/VirtualBoard/tests/dev_host.test.mjs b/Tools/VirtualBoard/tests/dev_host.test.mjs index 2d71c7d68..46316ae41 100644 --- a/Tools/VirtualBoard/tests/dev_host.test.mjs +++ b/Tools/VirtualBoard/tests/dev_host.test.mjs @@ -32,7 +32,7 @@ test('unowned existing configuration is neither read as a profile nor overwritte const result = await run(state) assert(result.error); assert.match(result.stderr,/EEXIST/) assert.equal(await readFile(join(state,'config.json'),'utf8'),content) - await assert.rejects(readFile(join(state,'virtual-development.json')), {code:'ENOENT'}) + assert.equal(await readFile(join(state,'virtual-development.json'),'utf8'),'','failed exclusive seed must not publish valid ownership') } finally { await rm(state,{recursive:true,force:true}) } }) test('owned remote configuration is refused without overwriting authoring', async () => { From 9f9c37b75b31b6b094375d0256e0c8c6467f3e9f Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 00:58:13 +0330 Subject: [PATCH 08/72] feat(mobile): add live channel state, telemetry charts and authenticated local automation [skip ci] --- mobile/ACCEPTANCE.md | 28 + mobile/AUTOMATION.md | 106 ++ mobile/README.md | 11 + .../android/app/src/main/AndroidManifest.xml | 3 + .../pakarcontroller/AutomationReceiver.kt | 54 ++ .../pakar/pakarcontroller/MainActivity.kt | 5 + mobile/lib/main.dart | 912 +++++++++++++++--- mobile/lib/pakar_reference.dart | 13 +- mobile/lib/unified/automation.dart | 327 +++++++ mobile/lib/unified/live_widgets.dart | 343 +++++++ mobile/lib/unified/transport.dart | 146 ++- mobile/pubspec.yaml | 2 +- mobile/test/unified_capture_test.dart | 76 ++ 13 files changed, 1899 insertions(+), 127 deletions(-) create mode 100644 mobile/AUTOMATION.md create mode 100644 mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/AutomationReceiver.kt create mode 100644 mobile/lib/unified/automation.dart create mode 100644 mobile/lib/unified/live_widgets.dart diff --git a/mobile/ACCEPTANCE.md b/mobile/ACCEPTANCE.md index 7c822e886..9affdaef2 100644 --- a/mobile/ACCEPTANCE.md +++ b/mobile/ACCEPTANCE.md @@ -15,6 +15,34 @@ physical safety admission are reused without adding an unrestricted proxy. screenshot inspection and read-only physical-host connection. - GitHub source ref, PR and observed CI status (not inferred green). +## Observed delivery, 2026-10-13 + +- Preview 0.1.1+2: ARM64 release build succeeded using the existing development + signing key. Full tests, analysis and CI waiting were skipped at the user's + request; only the requested opt-in Flutter screenshot renderer was executed. +- APK: 19,576,759 bytes, SHA-256 + `46dbf150bcee92776d4a1bd3b81ec40a0b6ff85bf9449ab0fd6d35754fda26ec`. +- The same APK was transferred and hash-verified via Termux SSH port 5022 at + `/sdcard/Download/PCController-0.1.1-preview-46dbf150.apk`. The previous + 0.1.0 APK is retained as a named rollback, not deleted generically. +- ADB became available again. Two advertisements were deduplicated by device + identity; one selected transport performed an in-place `install -r` with + `Success`. Installed version is 0.1.1/code 2; installed base.apk SHA matches + the delivered APK. No app data or drafts were cleared. +- Actual installed-phone captures were inspected: live voltage/current charts, + connected state, raw mirrored seven-segment digits and `TM1637: Detected`. + Existing saved peer settings reconnected without inserting a test peer or + creating a new network tunnel. No board writes/tone/flash were triggered. +- Separate phone/tablet light/dark renderer previews replay real read-only + board measurements with their original relative intervals. Their wall-clock + timestamps are shifted for replay; they are not live/tablet-device evidence. +- Android explicit broadcast reached the Dart JSON-RPC router and rejected an + invalid token with JSON-RPC `-32001`, as intended. Authorized HTTP/WS/TCP and + Intent command acceptance still need the user-enabled token and live client + acceptance. Automation defaults disabled; actuation is separately disabled. +- Implemented local automation and its limits are in [AUTOMATION.md](AUTOMATION.md). + No gRPC, background service, root grant or full native/Web parity is claimed. + ## Outstanding acceptance and feature work - Pealayer is currently offline on this workstation. Direct playback, cue diff --git a/mobile/AUTOMATION.md b/mobile/AUTOMATION.md new file mode 100644 index 000000000..c2deaa1c0 --- /dev/null +++ b/mobile/AUTOMATION.md @@ -0,0 +1,106 @@ +# Native-local automation (preview 0.1.1) + +Enable **Workspace → Automation → Enable local automation**, then copy the +token. Disabled by default. The app must remain running and foreground; pause +closes listeners, peers and subscriptions. Resume does not replay commands. +This is IPC (inter-process communication), not an unrestricted remote proxy. + +## Implemented transports + +| Interface | Endpoint | Authorization and receipt | +| --- | --- | --- | +| HTTP JSON-RPC 2.0 | `http://127.0.0.1:18990/rpc`, POST | `Authorization: Bearer TOKEN`; JSON response | +| WebSocket JSON-RPC | `ws://127.0.0.1:18990/ws` | Bearer upgrade header; request/response and subscribed app state | +| TCP NDJSON | `127.0.0.1:18991` | Each newline-delimited request includes `auth`; newline-delimited response | +| Android explicit Intent | `ir.digitalogic.pccontroller.mobile/.AutomationReceiver` | Action `ir.digitalogic.pccontroller.mobile.RPC`; string extras `rpc`, `auth`; ordered-broadcast result | + +No gRPC, shell execution, root grant, background foreground-service or direct +USB/Bluetooth board driver is provided. ADB, Termux, Tasker and root shells are +clients of these interfaces, not additional claimed protocols. Root is not +required. No Android API permission grants are silently requested. + +Only IPv4 loopback is bound. For ADB access from a trusted workstation: + +```sh +adb -s DEVICE forward tcp:18990 tcp:18990 +curl http://127.0.0.1:18990/rpc -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"app.state"}' +``` + +On Termux, use the same curl command without ADB forwarding. For Tasker, use +an HTTP Request with that Bearer header; or Send Intent with the explicit +component/action and string extras. Sending an Intent alone does not promise +a result in Tasker; use HTTP when a response is required. An ordered broadcast +from ADB/Termux can read the receiver result: + +```sh +adb -s DEVICE shell am broadcast \ + -n ir.digitalogic.pccontroller.mobile/.AutomationReceiver \ + -a ir.digitalogic.pccontroller.mobile.RPC \ + --es auth TOKEN \ + --es rpc '{"jsonrpc":"2.0","id":1,"method":"app.ping"}' +``` + +Within Termux replace `adb -s DEVICE shell am` with `termux-am`. A caller must +quote JSON for its shell; never put secrets in shared scripts or published +logs. Android may restrict broadcast visibility/background execution. The +receiver never starts a headless Flutter engine or launches the UI itself. +Its receipt timeout is eight seconds; timeout means outcome unknown, not an +instruction to retry. HTTP is preferred for larger replies. + +NDJSON example (single line, terminated by a newline): + +```json +{"auth":"TOKEN","jsonrpc":"2.0","id":1,"method":"app.state"} +``` + +WebSocket clients send `app.subscribe` after authentication. State notifications +use `app.state.changed`, coalesced to 250 ms. TCP is request/response only; +use WebSocket for push, including live front-panel/status state. + +## Methods and safety + +Read/UI methods: `app.ping`, `app.capabilities`, `app.state`, `app.subscribe`, +`app.navigation.set`, `app.capture`, `board.refresh`. +Navigation takes `section` (`pccontroller`, `pealayer`, `activities`, `workspace`) +and an optional PCController `page` (`monitor`, `outputs`, `front_panel`, +`firmware`, `effects`). Capture returns an app-only PNG, base64, width/height; +HTTP recommended. No desktop/other-app screen is captured. Max one capture per +second and 2 MiB PNG; capture is rejected over Intent to avoid Binder limits. + +Connection methods `board.connect` / `board.disconnect` use the already saved +route. They do not accept arbitrary destinations or forward peer credentials. + +Output methods: `board.action` (`action_id`), `board.pwm.set` (`channel`, +`percent`), `board.stop`, `board.estop`. Each requires `expected_target` equal +to the current `app.state.board.target`. Output actuation additionally needs +the separately confirmed **Allow output and playback actions** option. Motion +requires `confirm_motion: true`; action IDs/channels must be advertised and +unlocked. E-STOP blocks actuation. Stop/E-STOP assertion remain available to +authenticated callers without enabling actuation; E-STOP release, flash, +reset and arbitrary command execution are not exposed. Concurrent automation +mutations are rejected (safe stop/E-STOP can interrupt); nothing is retried. +Host safety admission still applies. Receipts do not prove physical completion. + +`player.call` requires opted-in actuation and the current +`app.state.player.target`. Its `method` allowlist is `pealayer.play`, +`pealayer.pause`, `pealayer.stop`, `pealayer.previous`, `pealayer.next`, +`pealayer.seek_to`, `pealayer.volume.set`, `pealayer.mute.toggle`, and +`pealayer.workspace.set`; peer arguments are in `params`. No arbitrary RPC +proxy or file-opening automation is exported. + +All requests require `jsonrpc: "2.0"` and a string/integer `id`. Batches and +fire-and-forget notifications are not dispatched. Request bounds: 32 KiB; +four WebSocket clients, four TCP clients, eight active requests and eight +queued NDJSON requests per client. No disk-based request log/history exists. +Tokens live in secure storage; revoke rotates the token and closes old clients. +Snapshots omit credentials, origins and media filenames. App capture can show +what the user sees, so possession of a token is an explicit privacy authority. + +## Evidence boundary + +ARM64 build and Flutter renderer captures are separate from installed-device +acceptance. The interfaces require live Android acceptance after installation +and enabling automation. gRPC and persistent background control remain future +work, not advertised capabilities. diff --git a/mobile/README.md b/mobile/README.md index 4c24cd189..d03541c88 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -25,6 +25,17 @@ PakarController remains a separate project and Android installation. Phones use bottom navigation; tablets use a rail and responsive multi-column cards. System light/dark preference and platform text scaling are retained. +The neutral card/action styling shares Pealayer's purple accent and semantic +state indicators rather than coloring every action as selected. Relay, motion +and PWM cards distinguish observed state from commands/setpoints; stale values +become Unknown. In-memory voltage/current/power and both temperature histories +retain at most 600 observations (at most 2 Hz), with gaps for missing readings. +No chart samples are persisted to disk. Live segment frames and TM1637 ACK +detection work independently of full LCD/menu snapshot availability. + +Optional authenticated local JSON-RPC (HTTP/WebSocket), TCP NDJSON and Android +Intent adapters are described in [Automation](AUTOMATION.md), including ADB, +Termux and Tasker usage, exact safety fences and background limitations. ## Connections and mutually routed controls diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 6c91b8497..db0140391 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,9 @@ + + + + try { + when (call.method) { + "initialSection" -> result.success(section()) + "launchers" -> result.success(aliases.keys.filter { enabled(it) }) + "setLaunchers" -> { + val selected = call.argument>("values")?.toSet() ?: error("Selection required") + require(selected.isNotEmpty() && aliases.keys.containsAll(selected)) + for (key in selected) activity.packageManager.setComponentEnabledSetting(component(key), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP) + for (key in aliases.keys - selected) activity.packageManager.setComponentEnabledSetting(component(key), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + result.success(null) + } + else -> result.notImplemented() + } + } catch (_: Exception) { result.error("preferences_failed", "Launcher settings unavailable", null) } + } + } + fun close() = channel.setMethodCallHandler(null) +} diff --git a/mobile/android/app/src/main/res/drawable-nodpi/pealayer_application.png b/mobile/android/app/src/main/res/drawable-nodpi/pealayer_application.png new file mode 100644 index 0000000000000000000000000000000000000000..cc61b74bd67973b8a7c6d1df4b01bcc570db3993 GIT binary patch literal 13738 zcmY*=by$>7^zXdO5(^R%q9~!HAc8b1>|)ResDx4mDIlnXQoD#4Al;21At@m#EQpjM zAR#4g1 z%WNK3%{^|o*?V~3eQXCbog7^6rkBOv2S8C&=ZeP7r_jncHZpE7X=-EPb9Fr=JbHx_ zo0Tvy74d;ScMOV5 zb~SWaT_p|R|IFqKyA{tpX`74jwn;^9erDJ>kEoVcaFR9EWN~W^7e%#5DLqS}G-xW` zN~E|?cf?<)jzHmVMgkz6TS(}<)_;R@s#GWbubMg|!Tz)uO3upUssFdppCY%8{?xrh zbH!db1#Z7Y0>|iEfz-CS46_IW(~WnG#FysBe$HVE8Sh1Z6-qxLdJZH9Awj-znG4$P zybo%7#q6(^HfPZL>+8B@_uMCo+1Xie$#ADzN1x8utK~GS6V3XC-6Kp{q9vOLVlO7d zyg4jM-}GA>;5h5*)@s&03~jSKO`~xcNMF<407+BkqE9_rY(-})RQ%S#<54mnPM%<%{|Y-%5Q(bVSqR~X}{4I-b7rlEh{ z{UxeFONre+$LnmTqQj8hHPU(4hbhui1azc6F(tP7$v-o*#wGLEa~*1bM3ESTY!9P= zoJLTZEGmq%L;U#h)1{m9wbjUbo9O`;+|n_o2G#dB#y z%?l*>kaT*ZR8N=UT`{Vq&D_r1y82EMrw|1)iJ7wC6xPVD{z1@R*jD*9PaoV7llW#AqQw@_O zm@ha+=P1ev6b#Bz(7d8j0cWS68q5yN^Z${gUYe!NO6Gt&v zz>UI8d1PRluX3@QNBuwuad;^wumvK8?$D8y)iFVW90iM9bo(a!+P$sRmrzm>)EG_HI-Ia|Dysk%nZ&mOspia#?a&}2_;trvTJ4lkQ{@X{Tc%>^tef(wv*<3aS@AF6sr)txJfh+DOxV zr&wa)PK=pgLW|oW6bA!bfk8}_8SRp+*eF*=+1H0du_jMhUIaeCt!aac@A;`#ykY2Q z%7TUXu^ck__6+{q$gH6=7H`UE?W@c7R$UZhp3eZEmnQjJ=%LAy`+mh39xz>={pf^i z&Dt-k7KB?Qcxvm8rBxNBFB4SP6Z-B4;sq{o7hAa-0$z|(c4ui9UK z>I5fXgBb)s6)kFUEHc`XanXX@^7ozX4pomwA zqOD}o`ybPO==9>Ln=+^?*1ijol&$GC7Ti)VZ=?VxPU(AJ0QwJgYX!ZTc*1{K6f$BV zA^I?gUd^%ek>UP0dBfKWS`@#UbY*EQ0o&Mahrw8BBSymna7xF14`IC>x}U23&NEu2 zHPkaN5K+_xa;t?57bURHpoe9+*6oIUsj4@8WR72~#Lm+F>UfxuvL9j)plm%ZF0t0M zk%z=wt`okWk*WGoZ5ZKrZnAWJ&;&clIvL;NKd-4_S4YC`cCaHcPkK)b`-lMPtTbv` zsCc@prcdjQ?+GQ*rTf%sr_Gf1wQDrfbjk?ZOByh6AiUMGI$oonuc!@fHxaI3P4_=N zBlS*x+qhB6gfoS^GSLRfU4#^h`OeUMle1xGpz$s>@YgAnBRwZhY?GLnQ9?pz;Df~Q zd>oWOukvg;QSaSJC6!Z-%y9k2vs}E_Ua5gD)pF26?KwI!d^}~S zxG_T$|LpGrTAU55s2es(1cJ@4U?g%N2$`v=&VVghNlPi&WluY*Yf64KH^At|1O%gy z10l#v2DrA4BGo@gje6gOCUu`QC1b?khR>0ix{dVzRfYO)9~JZ^V~#{2Z-yc>nPD;m zIknV$9_?{2aZo+-nFur}1SP(5YqlYf@t=sb9gUsDiYcC3VDZ3ogN#$ zmsVJVFKrft4K!l@62d6ZusLI&YA9RD*i@^HkUR-VwB&6xCRjer`5lF%^%BS^3Xr_( z;{HqoRP1?_u?zddgn}0OOAEq~MA-^_2V1E=F}k=;{Nvrh8!h;*Z@%%JFj{DxF8}t4 zlt%Kyam6zd@VMSJYfzmW zR6G|;QIKKlzeYpr?~+R`%P(DOQ4Ih;?rOES&o)X|qc2JZvZzDrqI<7?FO~+<;H?gi zDEWm5hCU6r_}+WsJ#>|;ZXtvBRj^M?hSJ^&jd*{vdC{95B!BDZs9F3sc3_YYgD0Wo zI!8*>-06tTIMEm(8PRNC5vR z2?onqY~&XzXWp~;OAzSXHC_ijf36EEXYm!zGnwBV_Am3TU-m|WS4d@Hp+s2TY`{At z?S|mz)8rTvTD@T7NiY@uF`QA8lj%8@RM}~GoI-66r0K3RP6@@V-H!CVH`Z&vApS+^ zGDkabI8WW0$>(^Eb|67dn{^En(t5}4C~LRb1+X;jgwr^l)3(|LWd0H8a59%_E~)gR znz|bZ+nUDRBONh2Q>FY=LqU@Ip8#O}jkFS&WB@GPNZ)OO%zy;1GM%ZwaM7tRZK7Ec_kqMVSt8i37Kr9upEyXE8QxTHhWgPW2{TqmHY z0C8{g))HgK75b(@e=I9?nK~74tM^f$ZE*P3V3T3CAkN4xN3e;EgsW+-ENJOv(@B|7 zUaM2L-X-i^OhaPZhzKT2F%t9{&vIw}GrAey8nE$8Z}UqT?P{kWu%D+BaWe~T|GWzI zs?uhL8)?tOuO_ij>n3SueguGZl$2V1E^NfVk4Xcw1qyBY+uFAU^(Wc3fvW^Gd8>VH z?}HfBM?w;M)c!qs$O%m0Gk-QIN#jz0#KDG3{BdTR4OjPOY9|5qM^Ivv) zx6Veqe)U-oQ0J4vmcydzTTjCF`kVgxkUhGg>(vxH7?fLPctuJJ(HmTUz|z#(hxek; zHh8IWTn_u5@%Zj-MljG-pB-pz0xYHWc_YYnC-DPFz(88c8P8o#@&7r~O z^Qu@l*=Y(bONs?x$)RX_$@ccf3tkIwHlFH6ZK$7XDahw2i<7oxY z)K&VXPY2yPoZMR@j<7vP1BC=-3#eNhl*Ca>|5{J`x8u_{hNft%uV5pXS?eKnXQ9zx z`Rk)=;@~!^=g(VtGnohdlcUEejic2nQwNhO<~m01sCxI~+V~CmlztS>IR`uGK#?D3 z2cx<5^6BpOkVSX}s;No-xUZ$;zH`4IJRc)z;qqq-69x@465dikb?b%7y}@$z(iAEa z6-0(hgrvDLdBB=qOtu*(z@XA=g9mQZXS;cU@bI@J+vA69ON#CcvJLmby-)@Wac3$xcmWow|vb>Pw<6%aF6G$p>e`UGJ_E?mlbaHBnHK zN*wa1rIa@4`Mu|&rmJuJ9|&su^b+I12@?2deht&5DiaAO(O~LSH6J2P8&qz{9I3EA zk@KAcC~xHfhsCV&{LR4?sZ3W3QC=5}%M5GujMh zHX|a3Hj|kl9|PZZO*c$^?C;%`ysif<&G*UXq@fC`K9LURqJ3|=sxq8DHyYG@BoRJs z)Bg1c3h&Wjyga=1!`>}^n9jZwu_UMK9 zo!MVj#h#}^LNV7rASx9h_q%gz@S&TCqkdXEz&!LI(!l8^G@5+S3(aq$v=7Zs0~{}^ z%%IWu1KLm<{&?vsSH>ZJB4mIC95}E4TjLzypJjk`YxIU^sNv|$lPQhDu@urwzLP#n z?#s{ff-n8uk{An4T%{Q;xQ}MiFvBuVz)la(!akd8V+BCk@V_a0&1dMqf=j$ipxfzS z?(u9x^vn`1nx%AesSCeK=AuACj;`?FPeR(>OOyOkc&WxalY$^~)R^AU2@$#5g-zRA z&|A##hj-PR)$UID<(%N#$>4o0iLg>G*iEJbx=HVug`@|{kwPnjw1Ps4>TdfpENCL? z2OMnmzEjeA;QXm4)$hONy}<^n(*D6szR9YNmFxQGapD3Uly=R98=^TgbZI_w>;U)p zZQO(@Jl1G1+)=rIyCq91zG!`ZSWt@!`y}3E)rd9#14H1k9&s-EHW9nBGkHYNh{OnP zwih|KMm6P%gS0d*3sF&ZescCUJIFVcVxUu0gS1U)HgU0g7`zi6jam(hm7R~S0{tdXRfx;0j1WbHCLRn~8 z%IHIbOyj6GDOIe!u}I8uuD4Ncy3C(%?F)+U(E&c&uJIoa7+iD_vi*s@eaP6Jku#DRp;&i$}e7eAE zb?p5yZR^plSA*y9WYsC4>(P{9!x=MdR{+~oa zRHY|r(qn5<@)1uVtQV=Q@X@Nj&dZQ&Zm}7yoWKq+p3$zj^xIWX!8KTm4SCAXZV+;#1i85{VhTP4%uNM!*wj z($OqXXEQfJ$gX#3N}ry~F|c%_-iK%49@E+L_1`m-IADI?Hz#bE%m^~o2iyJ?aN;@! zu#@g;maA0Sj&C~>00$FQo{!&{G2~pDRhcdQ4=ex!A4X?!TVg=Ioi;j8`rar}LV_P< z-x>2Jc>p+JI{2vR85iz7W%Ab|_UsxB|M;FZ0Pa?b?NA~72AkYQC-hL4C;kV~0Tr2K zv{z3O;NInUdsEFM-}tQH{)NYzb`WS{G1-Q?l7NT4!LDu!to&FJpd1pa0xMzNpnV`| zqE=nt$9{8-+2~w|?V3#t;C%!y?o^7l{@QQBj2q-#TB+sC!!1EASUD3(dF+|@5WTDW zG`%A>qG$UC(f=>`+_O5@W*g~--XBQZJR0Ci=aU&_+mB!dq+I7_sOW<^XZ%n+kPs=K zTRcRodwsyw<6hLu)n^&y=HT#F4LPTf=qO03m;IuVLY?);>AL7FB{ga=l; znv@ujUXE&tt;FtNmx7dAMPD84U%4i}F^koGJ1(3=vhP!j)MwTCUjgI|Z#EPFjq*Gs z@J$S8zF(1lX2o1nDr&>xK!ZTi9sL~(5PgX*lflxg0K7U)qXXqxUoe?loVYaKwZ*vD z8rjDLUblvSzrEl!4EvjWw5KOv@)>;oVn#R6YTg<4>uU1Eb59Or-6%P{PC+&$ets|U z0~BGEd}#nzbb5%%yads^%g}WFHvLoT@NC~hpMMeA@Rrq=>}PsvjhOm^#ISzFJI)>o z*sti*J`aE+ixhEW?YuaoeIId0v`64sk+cqudW_@%fyle#>}vjojobVSBH$7CpVpIn zi54SV?o*%g;42*`r0j-0!G*&T0$bT5?0_SAL>xMA@4v_Q=;pbn z%j^b-5vqN^9%wY$u38qzUhxJ4uH%@Z5<=GK!yl- zc@P2_vF}!*@PY!@!w+Z^Q4U0UPO`{byRfL=X(q;`;>+za>S8^#pXc zCY*^4O*k8?mtZB4VBKI|(pXZzuHIyKag2H_;(UV3;+VSWQRD!;yS1`Xfun#wbn)XF ztLpX_i(uYP;@<^sYyF7uH2;i7N9(bQo47v*Ma1&i&}v7QyuYv~pwx zy|VsM6P{fqRi(zHMKP_LMxoN=Y zQMjmUfIqUIjMLn3aj@asV+o?!UxQ?kO!@JRNB8&EB3|oV6DL=+mOA}0NI)5}r>_|4 zv(rgdQExz?!2RFU=>=;cy~Uwi8(|}<^p#9~alpWW^`X0~G0=|y(cARqkeQ%Qc z)6@uC&t=2On24D-uLsex5i{1d7s4+o>gwgv6x@g3s9DxTI9$+wbapBH9#;&EHhSY; z8Oodw7Pe$dcrwkFz9QQyrUP=@0bTwaQ7lZ|Re3)dTerpvNluTjC473MUVancLOedb zyV2{HLuVq9kaZ&6^@`X$laNv81oonsx|G@_`q-Hfwv=5$)%6&^1c6>ISd1dXVR~`~~ zefVjWvE+>8ohh!m=#I@`;X2*TyK_>>5e~<5Jjga&I8j8V&)laiaPoIOhR!Vs{3(WiaogTlp&V-n(WFYn`U%pXGc{&rk=?!N9a>-X{o!2Dd+ znv5}Wx0O5t^T4qt2_N#;SDeh&A}wDk!XCpHP*UTqf67pU_ENg6Z_eGR5luKkf|*ek zSC8y{Q7L)oSdS9y_5qyYbbifGFEllAoJx1Sc?khJJVs;{PEyk@f2|19h2u|Gj>u>y z8NS9YaBR3_b8-Tq!}t0*3g64F|5w6%@oQGuKbO{b@_#zOy&yhsHvaHmtpP|(#!Ut9 zJ%5zU+bIO|M|a$QW&_&>%s&YW`1B)R!c!Xiko9%Fq(lhO3uZa|iB3e0Lnqb`)@n}MHSIumOq~GoR z8!U>v1S@<*-?se}S9>->D4(}8l3D_53GVkUVxp6N?7%zNs% zI11yS59@l{+KH)<5x)Pgorjk(Q94I-MHaBpdA<(MYM`MS?N^10 zlmwCHp8bEKJ!7?7;059gPPBoKI?pN{5FzLEImeg?iT25ExXhlB_otwfiGn3a27A9h z$Def;#3>t}Vucqy)|$S^jRl1{vsniuYR;b)wtpo0ZtLgeC5Cq5)2erGrAH95_ZA_b z>!%tdm2-xgW^^IlRb-4RO!CTQ#IXe8t`|ORym&)O)aqn#FY+Z3-W4@}Gp+&shc`j$ z?DAx-b@_81PTdWcS3lDGksT^`rY_dmBnOu`t%TbLzqo#JEU=>1P!i0hS!E-pF4|1G z2N9G*$2W_45M6B*{3V^?;f6k-YTI?l1*eI*b7b6RyY}neAGb3LE(+h^Q~hgLzbm0) z2$DaP;Epr8b=F69xt4vZoEH8f$UE8MGxwvb~ejt;)5Mo=!pPqGxa^ zV9N%0wfM(#u6$0*rY`R{Ws=5jN)Z@Bp0FT$=gsfxyL^`BKk zVT6TxZ<1!JADkw{3;RB;+UE%Mx7#IWjjVk<1v?f%$6H5Jx;xYU3;yxxu;~Fw#;c7P zTb$tjkB_h+>XK-n%l!KpdFOM5^G(=F+5h%lE}0%#yC4G%A9x+V{e!_iU{BgSrzT0b z#s@*5gUPwj5I7={KuA7aiqKt-0%14+Y`@+lN`o7VTS-hz07vLf0p{E$B%Ze3gJRD& z18}AQhS0-({}t~YE7^V*g2K3DtHGHd7mu)-QM7Z|^}Rek!igQY_W6)MJY0YQ0fDJ= z!otDk)*1?g(yAU9DX$w|^gfg!54~O(J^);+A+2mZI~%H8xK7JxB#fg7-Xvk);3KWMt>0BDjK{JILiUy|fs+=qwo8O~0qf4M+TeZ~dG zFHc#++!PM8T2Gs+?3m3IgH}TrxVfjlaW%0Zn23V5n>EXKmMVw#D71#)3iwu zmXd1wmVbQjQ}drezlSC~xG!#!@%^wQm9EimL$q#FPejur3;@56wExT=40t$*0`0Iq z-ABz8+i4;2J5YrUR#kmbvFl^EK@={zvTCUh7V4N`8)e;Z>TiH*x;gOQJlI0Ap4Q~{ zl>UivsDfU?8dJHPM;jj_tUF5Mb90fI>#t;aH~`2KY4)og$e%~v$-3YC@L<6CpC~qa zir0m!;DWoB+8^xTNzYzaz;(3&0rJOSwE1T+Xsu&RTs`&xeSW#h|9EjQ^v}|A-Jcn~ zBg{r&KHq2sB}hy)R5$a+6#?WG5}ky6$zK_RtNFUlTGtt!wNm2mU4LW4&mRLHq^Hxa zq9ipk$@`(u;ik_^r)h-+GIYtV>m$^Y&N*fPX+TE{^mpsPuU;_Td#0Mq1Y7Lyb}$Un z`MbNnp3q5HzA~6mI$14)6_&xM-lpQ+R_>GzyenbGOtae^2TT*>yldZKB&hL>Hr~G# zFcCqcYTq3s!)A?tuD~=#c;DK|^ZgN$+6@s%B5z`o)>T^E)8zzK834k>bvnSr)N zi))cdkBuBSP6?eKJBz7lJASm+%o~xJ2fc5rZ2{%kzou+-3!#&u5SsYYalDxpGdWuW z7Ru{c%4)88iUY~2pI#70YKu&2(S<$U74q~sqCt?xPexKhfn5)tvlu9l)U>L;{!&s% zEPaQ(Ge4@abRBbttzj=-VaenA5n5hgOSI!{oy4=Fv9BO~rKJ!K92t1b*=NT`GW1o88OoQK(h@NMLmm zODhKX5+Q=I42r2>o4^__%XgIN`*W6L-q#*dLZPe=N!|~3m+-!er=TVN{RZs?JI8y$?|pw2FX?wsTy7Nl!|XOx=qTP8w#G zU{iab!~orGGZ%KC=_!griAkvSe8BW2*&<%i^Ek$7s_V-KU|@Uu=brb0b>t}X(IJwZuxNE&Bl*waKhE{!(s@Z}kF=df(3DEAR;lZ@Q70$2geU zd7~vUQjX}REdc@6fvL#?;V_?*9KA*J_^ez}@U5H`ATOu%-S(`{@aiSUA}i(gDo9$! z70EQ+0Q`KClm3B>D^g~;jUtdwgPYW=V`ZdQeVthNit*OtQdEQ_6vSjstQ%RwRLt;q*$aEmeXe8TQY=6vP@(iDlW3=VyO)pQ-s{fp7nb?)q4-9I)~(moWTk&L+y5 z>%=j#0mOk+I}E=~KE#*y?L4sBm*4Jj{`0HFg>P@i;|soOaRR|#SqZ<{=p_=P<6Vl6 zJ4H1+kY7csg<8Wc`RBI0X@4+dxkRTomO-{sgw8Sky|{;Y6e6ATFj*^5V88z0oK<4J zFFD@0G2bjv-hdEd;*0dOT%ih5uaDj(NlwoO3l5c7CsX#`T8>pzP6r@aw@U^5wAdP+ z%EdL(N$+z}j0gT@V{>ksLPk0~Q%|qccd+2@{M(05$718Wb&v&%bjGsX)jDiRCyx60 z`t{6aYclf$b&zp$_YFG>y3a+)GRDRE_W?SRP$N}uI0s2DBVC=u4KDJm$r@K0!0UZs zNbxmQ43+EDi?+a?NQ`^YWo=ULRcRz8fyBn@9_ck$qsRSfdQ{%eh z$`q#(sP1nVXlUZZ11RM7xjl!fcrkE@0rBPLAxD<$=Svp2_5~IRA^zCd2RlnPpA;Y? z`Jljj1PDBdN`#{Gylu(F)3SzxZ{kEiToRx zQHot)AP%%zA!51oDzj+S$W*&aPPR7gm`9ZW92mwl;!d z23sea&oxU9c)>yK`#Uy!PekeIe3c)@bzBqzve{4TC#QK0LwY<)ZPqSl{*G80VJ#LQ zTU?VwHNnxWajT858*_Q%2q5ZbfHa-(yLEECVn$2)w*Ty8Dz@sq?HyX%PNyL4HT))3 z7eUm0v+?Y=3yxt$1`LR&4dQjBAtSn;T|sUs7&GuVo?0jUB+L;7!_N1g$zLFv6iDf4DO(7O2$ZE3mwPetbg+UQ_L z)I(j0W0v{9Cxl}De{tM7*7eEBa_nLzIBN*U7i-EeaB!Q>y-&n=O}z#@F5Ph%fsxeS)gDUyFf3+7tm%3e zoCLhr(jZU!qhbX~+*5+F8KZhEHT1JK@KC~BFIb;B&J36m*mG#7WU4^OFx>Y%@~n;*7q=1dTo7EtOzWm_IxX=VYg9%T7N`7JIZ0d9b3LXz1mfK>r!X~}A8 z(gPef(HthpLA{X1Zy4ihj}6?cY_5izCZKRLB`_3D-cFvpbY?R)K@H?Eq_3bQOnqQU zN{@)ilMkL*KoTMPdDfpge!`0MJ$g)DY?Z6$Sq6|5oLW%AbOp zVhH=>A-0F{hrZb&GP9p2vBMbh1q1C&Dxp*)28ENf1sP-(U zAPFUUxzilLNaxo<`5sHA?b^Tqrf@Hc4Tq;|Bo0oAalgm`zTCLDT7B{j#Q8C^8iWY& zjiy$6U8<(rgdGn>_tx2qXYr|-QsAo3qsX+%-vUhFmc4BkUv113I*b|fCe2rQM>C2a zj_vPW@$$q@3i;c}=Y!b9Mq($!I5z@}*eY{Sdafd5L)n;TnD2rPwv^AghU|ftPjlkr zluG!AyDKqZJ>wd2?B-L0c9(6?b@vV}w^(O&4+lRovYTm*pX#fazny{-8Fk=o|7r@2 z=EO-x!utx%M1|9I@;PC0O~jOq8YhnLfLzZ+{REQ_ACO;7*<9enoj)L7V5088WGH`B124D9-8qew_nZJ2M1GDB=b%`?Sz+`F zD3$x?%|eG^^PL~#|IoNR^VD87g9VUQ`smibYdg6V0D*y~)xWU8yVWaq6WlJM#angK zxd?CrjEP4Rdoz9~A5?aB`=|W6+O|{#!cho}s%G2QQR&nTH+DD&apna8v3Y6KUma=t z{z1aetArp&`DplgG~|&-8%R?if8s@9bRF4{Aj)=-_BT&|VV>20tR3lk6%l#rAs3QE z?(DV>%<|1^b$lS@`-s*sC*U|_Pr49@Y=XAw0TVrWe^m%_XM$q|lcs zgA?*Kcio>U5$%xT|*r;9-ySiLdc+oMlQ3?R}1USflm(i)iGOF$CO3 z_(S!F9z4gVVKoc>zpXuLZycLg9rBt&poaN7>nVBED3AF?2JimAou(Mz{Sr#waJYHwoHR?RHLN; z?F`dj!}Q#{rRWM3`bdof!2IMoz|8&&XVG7H{2Z%f3}-0^z6(=pOBT;(0QpB2NG%uy z2U9K+BGK&nDzCHGcIo&h6F6NDK<{)oFm?*WU^t%`qkuw4Wc?OA%ZbA+R$(?HXb(9d zAxtbeNDINCQbmvB7~$u=Vr8Mc?JCHHgj2=olbOD5?5)m4?G=Cy39{u0EsN8H%Tcf? zfzx|NVs7qf=H_-CL7;WvME5hWC^}{`#VeGJk?YP8PSEw{3hV~Z51OPmZuiDH3%1ia20U4zOwtZRXYmtR2)T8x@JoD*lf8g z0^9{Gkn4=10ei4V-THJm|B+}&B`A{}9R->!MCi2HH3X@`5JrqU26DDbdlPMoyC3YB zdr3IM-%i(-aIbq=SQ!4WLI=(yT2gE)Sx|tM?5ce(8M+6FU*1m-v!9Bubhlb-8OfQeu44H=d95&Bix&fn#2Emog~-tcAS15{y<~k zo%=VTHIj5QWyt~BMlz?&71~{>wx*8|05=CTp5*ObK8Gg!y zDi95yYJY~m={#Jw6*w;h!A2|cXkvc7`8jRfLE@#ycjl6(3K{w}sQb310V2=N@qKCw z=lmPE3v*ugs9NmRu0D6;+3}Qz#R4Mex?=8tRkvtauHRvfM%CGxUFPSSv>sj59TCum zzsZ;ie3jlPF1V2Ouvo;g{EoqI*O^7stG3~!YB3v%pMgNMvIEzvIYE-*_2QWVN~-tB z&o67vqNrgn7t(?~$v=vFB3Xqm%;n+{gVy^+6A)n^`;zlD runApp(const UnifiedControllerApp()); -class UnifiedControllerApp extends StatelessWidget { +class UnifiedControllerApp extends StatefulWidget { const UnifiedControllerApp({super.key, this.restore = true}); final bool restore; @override - Widget build(BuildContext context) => MaterialApp( - title: 'PCController', - debugShowCheckedModeBanner: false, - themeMode: ThemeMode.system, - theme: _theme(Brightness.light), - darkTheme: _theme(Brightness.dark), - home: ControllerHome(restore: restore), + State createState() => _UnifiedControllerAppState(); +} + +class _UnifiedControllerAppState extends State { + final preferences = AppPreferences(); + @override + void dispose() { + preferences.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: preferences, + builder: (context, _) => MaterialApp( + title: 'PCController', + debugShowCheckedModeBanner: false, + themeMode: preferences.theme, + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: ControllerHome(restore: widget.restore, preferences: preferences), + ), ); ThemeData _theme(Brightness brightness) => ThemeData( useMaterial3: true, @@ -91,12 +109,19 @@ class UnifiedControllerApp extends StatelessWidget { chipTheme: ChipThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), - visualDensity: VisualDensity.standard, + visualDensity: preferences.compact + ? VisualDensity.compact + : VisualDensity.standard, ); } class ControllerHome extends StatefulWidget { - const ControllerHome({super.key, this.restore = true}); + const ControllerHome({ + super.key, + this.restore = true, + required this.preferences, + }); + final AppPreferences preferences; final bool restore; @override State createState() => _ControllerHomeState(); @@ -113,6 +138,14 @@ class _ControllerHomeState extends State draftProgram = TextEditingController(text: '{"steps": []}'); final logs = []; final history = TelemetryHistory(); + AppPreferences get preferences => widget.preferences; + late final buzzerMirror = BuzzerMirror( + failed: (error) { + preferences.mirrorBuzzer = false; + unawaited(preferences.save()); + if (mounted) setState(() => lastError = error); + }, + ); final captureKey = GlobalKey(); late final automation = LocalAutomation( dispatch: _automationCall, @@ -144,8 +177,18 @@ class _ControllerHomeState extends State super.initState(); WidgetsBinding.instance.addObserver(this); board.addListener(_changed); + preferences.addListener(_changed); + board.onBuzzerNote = (event) => unawaited(buzzerMirror.event(event)); + if (widget.restore) { + AppPreferences.native.setMethodCallHandler((call) async { + if (call.method == 'launch' && mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + setState(() => section = call.arguments == 'pealayer' ? 1 : 0); + } + }); + } player.addListener(_changed); - if (widget.restore) + if (widget.restore) { automationChannel.setMethodCallHandler((call) async { if (call.method != 'request') throw MissingPluginException(); final arguments = object(call.arguments); @@ -155,16 +198,32 @@ class _ControllerHomeState extends State intent: true, ); }); + } if (widget.restore) unawaited(_restore()); } void _changed() { history.record(board.state); + buzzerMirror.configure( + owner: board.identity, + generation: board.state['connection_generation'], + enabled: + foreground && + preferences.mirrorBuzzer && + board.boardReady && + board.subscribed, + volume: preferences.mirrorVolume, + ); if (mounted) setState(() {}); } Future _restore() async { try { + await preferences.load(); + final launch = await AppPreferences.native.invokeMethod( + 'initialSection', + ); + if (launch == 'pealayer') section = 1; final prefs = await SharedPreferences.getInstance(); boardOrigin = prefs.getString('pccontroller.origin') ?? @@ -212,8 +271,9 @@ class _ControllerHomeState extends State player.disconnect(); } else if (state == AppLifecycleState.resumed && widget.restore) { foreground = true; - if (automationEnabled && automationToken.isNotEmpty) + if (automationEnabled && automationToken.isNotEmpty) { unawaited(_startAutomation()); + } unawaited(connectBoard()); if (playerWanted && playerOrigin.isNotEmpty) { unawaited(player.connect(playerOrigin, playerToken)); @@ -226,6 +286,10 @@ class _ControllerHomeState extends State unawaited(automation.close()); if (widget.restore) automationChannel.setMethodCallHandler(null); WidgetsBinding.instance.removeObserver(this); + preferences.removeListener(_changed); + if (widget.restore) AppPreferences.native.setMethodCallHandler(null); + board.onBuzzerNote = null; + buzzerMirror.dispose(); board.removeListener(_changed); player.removeListener(_changed); board.dispose(); @@ -243,11 +307,12 @@ class _ControllerHomeState extends State await automation.start(automationToken); } catch (_) { // An optional IPC bind failure must not break saved board/player routes. - if (mounted) + if (mounted) { setState( () => lastError = 'Local API unavailable; check loopback ports 18990 and 18991.', ); + } } if (mounted) setState(() {}); } @@ -418,6 +483,7 @@ class _ControllerHomeState extends State params['seconds'] ?? params['value'] ?? params['target'] ?? + params['profile'] ?? params['workspace'] ?? ''; await perform(method, () async { @@ -546,18 +612,19 @@ class _ControllerHomeState extends State IconData icon, VoidCallback? callback, { bool priority = false, + bool? danger, }) => OutlinedButton.icon( onPressed: busy && !priority ? null : callback, style: OutlinedButton.styleFrom( minimumSize: const Size(40, 42), - foregroundColor: priority + foregroundColor: (danger ?? priority) ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.onSurface, - backgroundColor: priority + backgroundColor: (danger ?? priority) ? Theme.of(context).colorScheme.error.withValues(alpha: .08) : Colors.transparent, side: BorderSide( - color: priority + color: (danger ?? priority) ? Theme.of(context).colorScheme.error.withValues(alpha: .4) : Theme.of(context).colorScheme.outline, ), @@ -601,7 +668,7 @@ class _ControllerHomeState extends State style: Theme.of(context).textTheme.titleMedium, ), ), - if (trailing != null) trailing, + ?trailing, ], ), const SizedBox(height: 14), @@ -626,26 +693,66 @@ class _ControllerHomeState extends State }, ); Widget tabs(List values, int selected, ValueChanged change) => - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var i = 0; i < values.length; i++) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: ChoiceChip( - label: Text(values[i]), - selected: selected == i, - onSelected: (_) => change(i), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < values.length; i++) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: TextButton( + onPressed: () => change(i), + style: TextButton.styleFrom( + backgroundColor: selected == i + ? Theme.of(context).colorScheme.surface + : null, + foregroundColor: selected == i + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + side: selected == i + ? BorderSide( + color: Theme.of(context) + .colorScheme + .outlineVariant, + ) + : BorderSide.none, + textStyle: Theme.of(context).textTheme.labelLarge + ?.copyWith( + fontWeight: selected == i + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + child: Semantics( + selected: selected == i, + child: Text(values[i]), + ), + ), ), - ), - ], + ], + ), ), ); @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, bounds) { - final tablet = bounds.maxWidth >= 760; + final tablet = + preferences.layout == 'tablet' && bounds.maxWidth >= 600 || + preferences.layout == 'auto' && bounds.maxWidth >= 760; final pages = ['PCController', 'Pealayer', 'Activities', 'Workspace']; final icons = [ Icons.memory_outlined, @@ -710,6 +817,11 @@ class _ControllerHomeState extends State ], ), actions: [ + IconButton( + tooltip: 'Settings', + icon: const Icon(Icons.settings_outlined), + onPressed: openSettings, + ), IconButton( tooltip: 'Connect peers', icon: const Icon(Icons.link), @@ -858,8 +970,9 @@ class _ControllerHomeState extends State : '—'; String peripheral(String key, String fallback) { for (final entry in objects(board.peripherals['peripherals'])) { - if (entry['key'] == key && entry['name'] is String) + if (entry['key'] == key && entry['name'] is String) { return entry['name']; + } } return fallback; } @@ -1076,49 +1189,8 @@ class _ControllerHomeState extends State for (final control in group.value) card( '${control['name']}', - ['seat', 'side', 'motion'].contains(control['kind']) - ? Icons.airline_seat_recline_normal - : control['kind'] == 'relay' - ? Icons.power - : Icons.lightbulb_outline, + channelIcon(control), [ - Builder( - builder: (context) { - final observed = observeChannel( - control, - board.state, - board.pwmUpdated != null && - DateTime.now() - .difference(board.pwmUpdated!) - .inMilliseconds < - board.freshnessMs - ? board.pwmValues - : {}, - board.measurementsFresh, - ); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - StatePill( - observed.label, - active: observed.active, - ), - if (observed.percent != null) ...[ - const SizedBox(width: 12), - Expanded( - child: LinearProgressIndicator( - value: observed.percent! / 100, - minHeight: 4, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ], - ), - ); - }, - ), if (control['control'] == 'pwm-user') _PwmControl( key: ValueKey( @@ -1175,10 +1247,27 @@ class _ControllerHomeState extends State 'stop', 'off', ].contains(entry['verb']), + danger: entry['verb'] == 'stop', ), ], ), ], + color: channelColor(control['color']), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Builder( + builder: (context) { + final observed = channelObservation(control); + return StatePill( + observed.label, + active: observed.active, + ); + }, + ), + channelMenu(control), + ], + ), ), ]), ), @@ -1242,39 +1331,16 @@ class _ControllerHomeState extends State ? '${object(board.state['status'])['menu_page']}' : 'Unavailable', ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in [ - ('prev', Icons.arrow_back), - ('next', Icons.arrow_forward), - ('dec', Icons.remove), - ('inc', Icons.add), - ]) - action( - entry.$1, - entry.$2, - ready - ? () => unawaited(boardCommand('menu ${entry.$1}')) - : null, - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (var key = 1; key <= 4; key++) - action( - 'K$key', - Icons.touch_app, - full && panel['host_captured'] == true - ? () => unawaited(boardCommand('host-menu key K$key press')) - : null, - ), - ], + const SizedBox(height: 12), + FrontPanelButtons( + captured: full && panel['host_captured'] == true, + enabled: full && !busy, + pressedKeys: full && panel['pressed_keys'] is int + ? panel['pressed_keys'] as int + : board.measurementsFresh + ? object(board.state['status'])['active_keys'] as int? + : null, + dispatch: (command) => unawaited(boardCommand(command)), ), ]), card('Display message', Icons.text_fields, [ @@ -1351,29 +1417,75 @@ class _ControllerHomeState extends State ), ], ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - children: [ - for (final mode in ['idle', 'running']) - action( - mode, - Icons.settings, - ready - ? () => unawaited( - perform( - 'Host mode', - () => board.transport!.rpc( - 'controller.program_state.set', - {'mode': mode, 'owner': 'mobile'}, - ), - ), - ) - : null, + ]), + card( + 'Host mode', + Icons.tune, + [ + SegmentedButton( + emptySelectionAllowed: true, + segments: const [ + ButtonSegment( + value: 'idle', + label: Text('Idle'), + icon: Icon(Icons.pause_circle_outline), ), - ], + ButtonSegment( + value: 'running', + label: Text('Running'), + icon: Icon(Icons.play_circle_outline), + ), + ], + selected: + board.fresh && + [ + 'Idle', + 'Running', + ].contains(object(board.state['program_state'])['mode']) + ? { + '${object(board.state['program_state'])['mode']}' + .toLowerCase(), + } + : {}, + onSelectionChanged: ready && !busy + ? (values) { + if (values.isEmpty) return; + final stamp = board.targetStamp, peer = board.transport; + unawaited( + perform('Host mode', () async { + if (peer == null || + stamp != board.targetStamp || + !board.boardReady) { + throw const Rejected( + 'Board changed; reopen mode controls.', + ); + } + final result = await peer.rpc( + 'controller.program_state.set', + {'mode': values.single, 'owner': 'mobile'}, + ); + await board.refresh(); + return result; + }), + ); + } + : null, + ), + ], + trailing: StatePill( + board.fresh + ? '${object(board.state['program_state'])['mode'] ?? 'Unknown'}' + : 'Unknown', + active: + board.fresh && + [ + 'Idle', + 'Running', + ].contains(object(board.state['program_state'])['mode']) + ? object(board.state['program_state'])['mode'] == 'Running' + : null, ), - ]), + ), ]); } @@ -1632,6 +1744,23 @@ class _ControllerHomeState extends State }, ], ); + bool playerCan(String action) { + if (!playerReady) return false; + if (player.fresh) { + return object( + player.state['media_session_capabilities'], + )['can_$action'] == + true; + } + final selected = board.instances + .where((v) => v['id'] == bridgeTarget) + .firstOrNull; + return '${object(selected?['values'])['app_actions']}' + .split(',') + .map((v) => v.trim()) + .contains('pealayer.$action'); + } + Widget playback() { final state = player.state; return grid([ @@ -1645,23 +1774,33 @@ class _ControllerHomeState extends State maxLines: 3, ), const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ for (final entry in [ ('previous', Icons.skip_previous), - ('play', Icons.play_arrow), - ('pause', Icons.pause), + ( + player.fresh && state['playing'] == true ? 'pause' : 'play', + player.fresh && state['playing'] == true + ? Icons.pause + : Icons.play_arrow, + ), ('stop', Icons.stop), ('next', Icons.skip_next), ]) - action( - entry.$1, - entry.$2, - playerReady - ? () => unawaited(playerCommand('pealayer.${entry.$1}')) - : null, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton.filledTonal( + tooltip: entry.$1[0].toUpperCase() + entry.$1.substring(1), + icon: Icon( + entry.$2, + size: entry.$1 == 'play' || entry.$1 == 'pause' ? 30 : 24, + ), + style: IconButton.styleFrom(minimumSize: const Size(48, 48)), + onPressed: playerCan(entry.$1) + ? () => unawaited(playerCommand('pealayer.${entry.$1}')) + : null, + ), ), ], ), @@ -1675,32 +1814,33 @@ class _ControllerHomeState extends State 'Position', '${(state['playback_time'] as num).toStringAsFixed(1)} / ${(state['duration'] as num).toStringAsFixed(1)} s', ), - Slider( - value: (state['playback_time'] as num).toDouble().clamp( - 0, - (state['duration'] as num).toDouble(), - ), - max: (state['duration'] as num).toDouble(), - onChanged: busy ? null : (_) {}, - onChangeEnd: (value) => unawaited( - playerCommand('pealayer.seek_to', {'seconds': value}), - ), + RemoteSlider( + key: ValueKey('${player.targetStamp}/seek'), + value: (state['playback_time'] as num).toDouble(), + maximum: (state['duration'] as num).toDouble(), + enabled: !busy, + label: (v) => '${v.toStringAsFixed(1)} s', + changed: (v) => + unawaited(playerCommand('pealayer.seek_to', {'seconds': v})), ), ], if (player.fresh && state['volume'] is num) ...[ fact('Volume', '${(state['volume'] as num).round()}%'), - Slider( - value: (state['volume'] as num).toDouble().clamp(0, 100), - max: 100, - onChanged: busy ? null : (_) {}, - onChangeEnd: (value) => unawaited( - playerCommand('pealayer.volume.set', {'value': value}), - ), + RemoteSlider( + key: ValueKey('${player.targetStamp}/volume'), + value: (state['volume'] as num).toDouble(), + maximum: 130, + enabled: !busy, + label: (v) => '${v.round()}%', + changed: (v) => + unawaited(playerCommand('pealayer.volume.set', {'value': v})), ), ], action( - 'Toggle mute', - Icons.volume_off_outlined, + player.fresh && state['muted'] == true ? 'Unmute' : 'Mute', + player.fresh && state['muted'] == true + ? Icons.volume_off_outlined + : Icons.volume_up_outlined, playerReady ? () => unawaited(playerCommand('pealayer.mute.toggle')) : null, @@ -1726,26 +1866,162 @@ class _ControllerHomeState extends State ) : null, ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final mode in ['simple', 'nle']) - action( - mode, - Icons.dashboard_outlined, - playerReady - ? () => unawaited( - playerCommand('pealayer.workspace.set', { - 'workspace': mode, - }), - ) - : null, + if (player.fresh && + objects(state['workspace_profiles']).isNotEmpty) ...[ + const SizedBox(height: 16), + DropdownButtonFormField( + key: ValueKey( + '${player.targetStamp}/${state['active_workspace_profile']}', + ), + initialValue: + objects(state['workspace_profiles']) + .any((v) => v['id'] == state['active_workspace_profile']) + ? state['active_workspace_profile'] as String + : null, + decoration: const InputDecoration(labelText: 'Workspace'), + items: [ + for (final profile in objects(state['workspace_profiles'])) + DropdownMenuItem( + value: '${profile['id']}', + child: Text( + '${profile['name']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: !busy + ? (v) { + if (v != null) { + unawaited( + playerCommand('pealayer.workspace.set', {'profile': v}), + ); + } + } + : null, + ), + ], + ], color: const Color(0xff79bc63)), + if (player.fresh && state['playback_rate'] is num) + card('Playback options', Icons.tune, [ + DropdownButtonFormField( + key: ValueKey('${player.targetStamp}/${state['playback_rate']}'), + initialValue: (state['playback_rate'] as num).toDouble(), + decoration: const InputDecoration(labelText: 'Speed'), + items: [ + for (final rate in ({ + .25, + .5, + .75, + 1.0, + 1.25, + 1.5, + 2.0, + (state['playback_rate'] as num).toDouble(), + }.toList()..sort())) + DropdownMenuItem( + value: rate, + child: Text('${rate.toStringAsFixed(2)}×'), + ), + ], + onChanged: !busy + ? (v) { + if (v != null) { + unawaited( + playerCommand('pealayer.rate.set', {'rate': v}), + ); + } + } + : null, + ), + if (objects(state['chapters']).isNotEmpty) ...[ + const SizedBox(height: 16), + DropdownButtonFormField( + key: ValueKey( + '${player.targetStamp}/${state['current_chapter_index']}', ), + initialValue: + objects(state['chapters']) + .any((v) => v['index'] == state['current_chapter_index']) + ? state['current_chapter_index'] as int + : null, + decoration: const InputDecoration(labelText: 'Chapter'), + items: [ + for (final chapter in objects(state['chapters'])) + DropdownMenuItem( + value: chapter['index'] as int, + child: Text( + '${chapter['title']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: !busy + ? (v) { + if (v != null) { + unawaited( + playerCommand('pealayer.chapter.set', {'index': v}), + ); + } + } + : null, + ), ], - ), - ], color: const Color(0xff79bc63)), + for (final kind in ['audio', 'sub']) + if (objects(state['media_tracks']) + .any((v) => v['kind'] == kind && v['id'] is int)) ...[ + const SizedBox(height: 16), + DropdownButtonFormField( + key: ValueKey( + '${player.targetStamp}/$kind/${objects(state['media_tracks']).where((v) => v['kind'] == kind && v['selected'] == true).map((v) => v['id']).join()}', + ), + initialValue: + objects(state['media_tracks']) + .where( + (v) => + v['kind'] == kind && + v['selected'] == true && + v['id'] is int, + ) + .firstOrNull?['id'] + as int? ?? + -1, + isExpanded: true, + decoration: InputDecoration( + labelText: kind == 'audio' ? 'Audio track' : 'Subtitles', + ), + items: [ + const DropdownMenuItem(value: -1, child: Text('Off')), + for (final track in objects( + state['media_tracks'], + ).where((v) => v['kind'] == kind && v['id'] is int)) + DropdownMenuItem( + value: track['id'] as int, + child: Text( + '${track['title'] ?? track['language'] ?? track['codec'] ?? track['id']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: !busy + ? (v) { + if (v != null) { + unawaited( + playerCommand( + v == -1 + ? 'pealayer.media.track.disable' + : 'pealayer.media.track.select', + {'kind': kind, if (v != -1) 'id': v}, + ), + ); + } + } + : null, + ), + ], + ], color: const Color(0xff79bc63)), ]); } @@ -1794,7 +2070,7 @@ class _ControllerHomeState extends State for (final cue in objects(player.state['cues'])) ListTile( title: Text('${cue['name'] ?? cue['reference'] ?? cue['id']}'), - subtitle: Text('${cue['start_time_ms'] ?? cue['start_time'] ?? ''}'), + subtitle: Text('${cue['start_time_ms']} ms'), onTap: player.fresh && !busy ? () => unawaited( playerCommand('timeline.cue.edit', {'instance_id': cue['id']}), @@ -2034,6 +2310,10 @@ class _ControllerHomeState extends State ), ]), const SizedBox(height: 16), + automationSettings(), + ], + ); + Widget automationSettings() => card('Automation / local API', Icons.hub_outlined, [ SwitchListTile( contentPadding: EdgeInsets.zero, @@ -2057,11 +2337,15 @@ class _ControllerHomeState extends State !await confirm( 'Allow automation actions?', 'Authorized local clients can operate the selected board and player. Motion also requires an explicit confirmed target.', - )) + )) { return; + } final prefs = await SharedPreferences.getInstance(); await prefs.setBool('automation.actuation', value); - if (mounted) setState(() => automationActuation = value); + if (mounted) { + setState(() => automationActuation = value); + preferences.refresh(); + } }, ), Wrap( @@ -2081,9 +2365,102 @@ class _ControllerHomeState extends State ], ), ], - ]), - ], + ]); + + void openSettings() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AnimatedBuilder( + animation: Listenable.merge([preferences, board, player]), + builder: (context, _) => AppSettings( + preferences: preferences, + changed: _changed, + connections: card('Connections', Icons.link, [ + action( + 'PCController connection', + Icons.memory_outlined, + () => configure(true), + ), + const SizedBox(height: 8), + action( + 'Pealayer connection', + Icons.play_circle_outline, + () => configure(false), + ), + ]), + automation: automationSettings(), + ), + ), + ), + ); + } + + ChannelObservation channelObservation(Json control) => observeChannel( + control, + board.state, + board.pwmUpdated != null && + DateTime.now().difference(board.pwmUpdated!).inMilliseconds < + board.freshnessMs + ? board.pwmValues + : {}, + board.measurementsFresh, ); + + Widget channelMenu(Json control) { + final profile = object(board.peripherals['board_profile']); + final editable = + board.fresh && + !busy && + profile['attached'] == true && + profile['configured'] == true; + return PopupMenuButton( + tooltip: 'Manage ${control['name']}', + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (value) => unawaited(editChannel(control, value)), + itemBuilder: (_) => [ + PopupMenuItem( + value: 'edit', + enabled: editable, + child: const Text('Edit channel'), + ), + PopupMenuItem( + value: 'lock', + enabled: editable, + child: Text( + control['locked'] == true ? 'Unlock channel' : 'Lock channel', + ), + ), + ], + ); + } + + Future editChannel(Json control, String operation) async { + final stamp = board.targetStamp, transport = board.transport; + final revision = object(board.peripherals['board_profile'])['revision']; + Json? changes; + if (operation == 'lock') { + changes = {'locked': control['locked'] != true}; + } else { + changes = await showDialog( + context: context, + builder: (_) => ChannelEditor(control: control), + ); + } + if (changes == null || !mounted) return; + await perform('Channel presentation', () async { + if (!board.fresh || board.targetStamp != stamp || transport == null) { + throw const Rejected('Channel owner changed. Reopen the channel menu.'); + } + await transport.rpc('controller.peripheral.presentation.update', { + 'key': control['key'], + 'expected_revision': revision, + ...changes!, + }); + await board.refresh(); + return null; + }); + } + Future editEffect(Json initial) => showDialog( context: context, barrierDismissible: false, @@ -2117,6 +2494,7 @@ class _ControllerHomeState extends State if (foreground) await automation.start(automationToken); } automationEnabled = enabled; + preferences.refresh(); final prefs = await SharedPreferences.getInstance(); await prefs.setBool('automation.enabled', enabled); return null; @@ -2156,7 +2534,7 @@ class _ControllerHomeState extends State Future _automationCall(String method, Json params) async { switch (method) { case 'app.ping': - return {'ok': true, 'app': 'PCController', 'version': '0.1.1'}; + return {'ok': true, 'app': 'PCController', 'version': '0.1.2'}; case 'app.capabilities': return { 'jsonrpc': '2.0', @@ -2169,6 +2547,20 @@ class _ControllerHomeState extends State 'actuation_enabled': automationActuation, 'batch': false, 'notifications_dispatch': false, + 'board_methods': [ + 'board.refresh', + 'board.connect', + 'board.disconnect', + 'board.action', + 'board.pwm.set', + 'board.stop', + 'board.estop', + 'board.menu.press', + 'board.display.show', + 'board.buzzer.play', + 'board.buzzer.stop', + 'board.mode.set', + ], }; case 'app.state': return automationSnapshot(); @@ -2186,8 +2578,9 @@ class _ControllerHomeState extends State 'workspace', ].indexOf('${params['section']}'); if (next < 0) throw const AutomationFailure(-32602, 'Unknown section'); - if (!foreground) + if (!foreground) { throw const AutomationFailure(-32002, 'App is not foreground'); + } final page = params['page']; final boardPage = [ 'monitor', @@ -2196,39 +2589,45 @@ class _ControllerHomeState extends State 'firmware', 'effects', ].indexOf('$page'); - if (page != null && (next != 0 || boardPage < 0)) + if (page != null && (next != 0 || boardPage < 0)) { throw const AutomationFailure(-32602, 'Unknown section page'); + } setState(() { section = next; if (page != null) boardTab = boardPage; }); - return {'section': params['section'], if (page != null) 'page': page}; + return {'section': params['section'], 'page': ?page}; case 'app.capture': - if (!foreground) + if (!foreground) { throw const AutomationFailure(-32002, 'App is not foreground'); + } if (_captureInFlight || (_lastCapture != null && - DateTime.now().difference(_lastCapture!).inMilliseconds < 1000)) + DateTime.now().difference(_lastCapture!).inMilliseconds < + 1000)) { throw const AutomationFailure( -32003, 'Capture is busy; limited to one per second', ); + } _captureInFlight = true; try { await WidgetsBinding.instance.endOfFrame; final boundary = captureKey.currentContext?.findRenderObject(); - if (boundary is! RenderRepaintBoundary) + if (boundary is! RenderRepaintBoundary) { throw const AutomationFailure( -32002, 'No rendered application frame', ); + } final image = await boundary.toImage(pixelRatio: 1.5); try { final bytes = await image.toByteData( format: ui.ImageByteFormat.png, ); - if (bytes == null || bytes.lengthInBytes > 2097152) + if (bytes == null || bytes.lengthInBytes > 2097152) { throw const AutomationFailure(-32003, 'Capture exceeds 2 MiB'); + } return { 'mime_type': 'image/png', 'png_base64': base64Encode(bytes.buffer.asUint8List()), @@ -2246,8 +2645,9 @@ class _ControllerHomeState extends State await board.refresh(); return automationSnapshot()['board']; case 'board.connect': - if (!foreground) + if (!foreground) { throw const AutomationFailure(-32002, 'App is not foreground'); + } boardWanted = true; connectionChoiceRevision++; await connectBoard(); @@ -2259,39 +2659,135 @@ class _ControllerHomeState extends State case 'board.pwm.set': case 'board.stop': case 'board.estop': + case 'board.menu.press': + case 'board.display.show': + case 'board.buzzer.play': + case 'board.buzzer.stop': + case 'board.mode.set': if (!foreground || !board.boardReady || - params['expected_target'] != board.targetStamp) + params['expected_target'] != board.targetStamp) { throw const AutomationFailure( -32002, 'Board is unavailable or target changed', ); + } final peer = board.transport!, - stop = method == 'board.stop' || method == 'board.estop'; - if (busy && !stop) + stop = + method == 'board.stop' || + method == 'board.estop' || + method == 'board.buzzer.stop'; + if (busy && !stop) { throw const AutomationFailure(-32003, 'Application is busy'); - if (method == 'board.estop') + } + if (method == 'board.estop') { return peer.rpc('controller.command.execute', { 'command': 'estop on', }); + } if (method == 'board.stop') { final stamp = board.targetStamp; await peer.rpc('controller.command.execute', { 'command': 'relay off', }); - if (stamp != board.targetStamp || !board.boardReady) + if (stamp != board.targetStamp || !board.boardReady) { throw const AutomationFailure( -32002, 'Target changed during stop; refresh to check outcome', ); + } return peer.rpc('controller.pwm.off'); } + if (method == 'board.buzzer.stop') { + buzzerMirror.stop(); + return peer.rpc('controller.command.execute', { + 'command': 'melody stop', + }); + } if (!automationActuation || - object(board.state['emergency_stop'])['active'] == true) + object(board.state['emergency_stop'])['active'] == true) { throw const AutomationFailure( -32004, 'Automation actuation is not enabled or E-STOP is active', ); + } + if (method == 'board.menu.press') { + if (board.state['have_front_panel'] != true) { + throw const AutomationFailure( + -32002, + 'Front-panel mode is not yet available', + ); + } + final button = params['button']; + final panel = object(board.state['front_panel']); + final captured = + board.state['have_front_panel'] == true && + panel['host_captured'] == true; + final allowed = captured + ? ['K1', 'K2', 'K3', 'K4'] + : ['prev', 'next', 'dec', 'inc']; + if (!allowed.contains(button)) { + throw const AutomationFailure( + -32602, + 'Button is not available in the current panel mode', + ); + } + return peer.rpc('controller.command.execute', { + 'command': captured + ? 'host-menu key $button press' + : 'menu $button', + }); + } + if (method == 'board.display.show') { + final target = params['display'], text = params['text']; + final seconds = params['duration_seconds'] ?? 5; + if (!['segments', 'lcd'].contains(target) || + text is! String || + text.trim().isEmpty || + text.length > 240 || + seconds is! int || + seconds < 1 || + seconds > 30 || + (target == 'lcd' && + (board.state['have_front_panel'] != true || + object(board.state['front_panel'])['lcd_available'] != + true))) { + throw const AutomationFailure( + -32602, + 'Display or bounded message is invalid', + ); + } + return peer.rpc('controller.command.execute', { + 'command': + 'display $target --duration ${seconds}s --repeat once -- ${jsonEncode(text)}', + }); + } + if (method == 'board.buzzer.play') { + final hz = params['frequency_hz'], ms = params['duration_ms']; + if (hz is! int || + hz < 20 || + hz > 20000 || + ms is! int || + ms < 1 || + ms > 1000) { + throw const AutomationFailure( + -32602, + 'Tone requires 20..20000 Hz and 1..1000 ms', + ); + } + return peer.rpc('controller.command.execute', { + 'command': 'buzzer $hz $ms', + }); + } + if (method == 'board.mode.set') { + if (!['idle', 'running'].contains(params['mode'])) { + throw const AutomationFailure(-32602, 'Unknown mode'); + } + return peer.rpc('controller.program_state.set', { + 'mode': params['mode'], + 'owner': 'mobile-automation', + }); + } if (method == 'board.pwm.set') { final channel = params['channel'], percent = params['percent']; if (channel is! int || @@ -2304,11 +2800,12 @@ class _ControllerHomeState extends State c['key'] == 'pwm.$channel' && c['control'] == 'pwm-user' && c['locked'] != true, - )) + )) { throw const AutomationFailure( -32602, 'PWM target/value is not advertised', ); + } return peer.rpc('controller.pwm.set', { 'channel': channel, 'percent': percent, @@ -2317,15 +2814,17 @@ class _ControllerHomeState extends State for (final control in objects(board.peripherals['controls'])) { for (final action in objects(control['actions'])) { if (action['id'] != params['action_id']) continue; - if (control['locked'] == true) + if (control['locked'] == true) { throw const AutomationFailure(-32004, 'Channel is locked'); + } if (['seat', 'side', 'motion'].contains(control['kind']) && action['verb'] != 'stop' && - params['confirm_motion'] != true) + params['confirm_motion'] != true) { throw const AutomationFailure( -32004, 'Motion requires an explicit confirmed target', ); + } return peer.rpc('controller.action.invoke', { 'action_id': action['id'], }); @@ -2340,11 +2839,12 @@ class _ControllerHomeState extends State !player.fresh || params['expected_target'] != player.targetStamp || !automationActuation || - busy) + busy) { throw const AutomationFailure( -32002, 'Player is unavailable, target changed or actuation disabled', ); + } final action = params['method']; if (![ 'pealayer.play', @@ -2356,8 +2856,9 @@ class _ControllerHomeState extends State 'pealayer.volume.set', 'pealayer.mute.toggle', 'pealayer.workspace.set', - ].contains(action)) + ].contains(action)) { throw const AutomationFailure(-32602, 'Player method is not allowed'); + } return player.transport!.rpc(action, object(params['params'])); default: throw const AutomationFailure(-32601, 'Method not found'); diff --git a/mobile/lib/unified/automation.dart b/mobile/lib/unified/automation.dart index dcfafd01f..6f4b632ad 100644 --- a/mobile/lib/unified/automation.dart +++ b/mobile/lib/unified/automation.dart @@ -26,8 +26,9 @@ String newAutomationToken() { bool sameSecret(String a, String b) { if (a.length != b.length || a.isEmpty) return false; var difference = 0; - for (var i = 0; i < a.length; i++) + for (var i = 0; i < a.length; i++) { difference |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } return difference == 0; } @@ -52,8 +53,9 @@ class LocalAutomation { static const httpPort = 18990, tcpPort = 18991, maximum = 32768; Future start(String secret) async { if (running) return; - if (secret.length < 32) + if (secret.length < 32) { throw const AutomationFailure(-32001, 'Invalid automation secret'); + } final epoch = ++_epoch; HttpServer? http; ServerSocket? tcp; @@ -102,33 +104,41 @@ class LocalAutomation { bool intent = false, }) async { final request = object(input), id = request['id']; - if (!allowed || !sameSecret(auth, token)) + if (!allowed || !sameSecret(auth, token)) { return error( id, -32001, 'Automation is disabled or authorization failed', ); + } if (request['jsonrpc'] != '2.0' || request['method'] is! String || (id != null && id is! String && id is! int) || (id is String && id.length > 128) || - (request['params'] != null && request['params'] is! Map)) + (request['params'] != null && request['params'] is! Map)) { return error(id, -32600, 'Invalid JSON-RPC request'); + } // Mutating notifications have no receipt, so they are not dispatched. if (!request.containsKey('id')) return null; if (_requests >= 8) return error(id, -32003, 'Too many active requests'); - if (intent && request['method'] == 'app.capture') + if (intent && request['method'] == 'app.capture') { return error(id, -32602, 'Use authenticated HTTP for app capture'); + } final method = request['method'] as String; final mutating = [ 'board.action', 'board.pwm.set', 'board.connect', 'board.disconnect', + 'board.menu.press', + 'board.display.show', + 'board.buzzer.play', + 'board.mode.set', 'player.call', ].contains(method); - if (mutating && _mutating) + if (mutating && _mutating) { return error(id, -32003, 'Another automation action is pending'); + } if (mutating) _mutating = true; _requests++; try { @@ -187,13 +197,16 @@ class LocalAutomation { final input = jsonDecode(data); final response = await receive(input, secret); if (object(input)['method'] == 'app.subscribe' && - response?.containsKey('result') == true) + response?.containsKey('result') == true) { _subscriptions.add(socket); - if (response != null && socket.readyState == WebSocket.open) + } + if (response != null && socket.readyState == WebSocket.open) { socket.add(jsonEncode(response)); + } } catch (_) { - if (socket.readyState == WebSocket.open) + if (socket.readyState == WebSocket.open) { socket.add(jsonEncode(error(null, -32700, 'Invalid JSON'))); + } } }, onDone: () { @@ -215,15 +228,17 @@ class LocalAutomation { try { final bytes = []; await for (final chunk in request.timeout(const Duration(seconds: 5))) { - if (bytes.length + chunk.length > maximum) + if (bytes.length + chunk.length > maximum) { throw const AutomationFailure(-32600, 'Request exceeds 32 KiB'); + } bytes.addAll(chunk); } final response = await receive(jsonDecode(utf8.decode(bytes)), secret); - if (response == null) + if (response == null) { request.response.statusCode = 204; - else + } else { request.response.write(jsonEncode(response)); + } } catch (_) { request.response.statusCode = 400; request.response.write( @@ -297,8 +312,9 @@ class LocalAutomation { }); if (frame.length > 262144) return; for (final client in List.of(_subscriptions)) { - if (client is WebSocket && client.readyState == WebSocket.open) + if (client is WebSocket && client.readyState == WebSocket.open) { client.add(frame); + } // TCP is request/response and subscribe receipts; use WS for pushed state // rather than bypassing stream backpressure with a repeating write loop. } diff --git a/mobile/lib/unified/buzzer_mirror.dart b/mobile/lib/unified/buzzer_mirror.dart new file mode 100644 index 000000000..cb5e64a9d --- /dev/null +++ b/mobile/lib/unified/buzzer_mirror.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import '../device_registry.dart' show randomIdentifier; +import '../mirroring.dart' show ToneOutput, AndroidToneOutput; +import 'transport.dart' show Json, object; + +/// Current PCController buzzer.note contract. The selected live connection owns +/// audio; queued notes are never retained across a lost/changed owner. +class BuzzerMirror { + BuzzerMirror({ToneOutput? output, this.failed}) + : output = output ?? AndroidToneOutput(); + final ToneOutput output; + final void Function(String)? failed; + final clock = Stopwatch()..start(); + final pending = []; + Timer? _lease; + String _owner = '', _generation = ''; + String? _session; + bool _enabled = false, _opening = false; + double _volume = .35; + int _epoch = 0, _lastId = 0; + int? _device; + double? _start; + + void configure({ + required String owner, + required Object? generation, + required bool enabled, + required double volume, + }) { + if (owner == _owner && + '$generation' == _generation && + enabled == _enabled && + volume == _volume) { + return; + } + stop(); + _owner = owner; + _generation = '$generation'; + _enabled = enabled; + _volume = volume; + } + + Future event(Json event) async { + if (!_enabled || _owner.isEmpty || event['kind'] != 'buzzer.note') return; + final data = object(event['metadata']); + // Bridged source clocks do not belong to the directly selected board. + if (data.containsKey('bridge.ingress') || + '${data['connection_generation']}' != _generation) { + return; + } + final id = event['id']; + final hz = int.tryParse('${data['frequency_hz']}'); + final ms = int.tryParse('${data['duration_ms']}'); + final micros = data.containsKey('device_micros') + ? int.tryParse('${data['device_micros']}') + : null; + if (id is! int || + id <= _lastId || + hz == null || + ms == null || + ms < 0 || + ms > 65535 || + (hz != 0 && (hz < 20 || hz > 20000 || ms == 0)) || + (data.containsKey('device_micros') && + (micros == null || micros < 0 || micros > 0xffffffff))) { + return; + } + _lastId = id; + if (hz == 0 && ms == 0) { + for (final timer in pending) { + timer.cancel(); + } + pending.clear(); + _device = null; + _start = null; + final session = _session; + if (session != null) await _call(output.note(session, 0, 0)); + return; + } + final now = clock.elapsedMicroseconds / 1000; + var start = now + 8; + if (micros != null) { + if (_device != null && _start != null) { + final delta = (micros - _device!) & 0xffffffff; + final candidate = _start! + delta / 1000; + if (delta <= 300000000 && (candidate - now).abs() <= 250) { + start = candidate; + } + } + _device = micros; + _start = start; + } + final end = start + ms, epoch = _epoch; + if (_session == null) { + if (_opening) return; // No backlog while the native receiver opens. + _opening = true; + final session = randomIdentifier(); + _session = session; + try { + await output.open(session, _volume); + if (epoch != _epoch || !_enabled) { + await output.stop(session); + return; + } + _lease = Timer.periodic(const Duration(seconds: 2), (_) { + if (epoch == _epoch) unawaited(_call(output.lease(session))); + }); + } catch (_) { + if (epoch == _epoch) _fail(); + return; + } finally { + if (epoch == _epoch) _opening = false; + } + } + if (epoch != _epoch || end <= clock.elapsedMicroseconds / 1000) return; + if (pending.length >= 32) { + _fail(); + return; + } + late final Timer timer; + timer = Timer( + Duration( + microseconds: ((start - clock.elapsedMicroseconds / 1000) * 1000) + .round() + .clamp(0, 250000), + ), + () { + pending.remove(timer); + if (epoch != _epoch || !_enabled || _session == null) return; + final remaining = (end - clock.elapsedMicroseconds / 1000).ceil(); + if (remaining > 0) { + unawaited( + _call(output.note(_session!, hz, remaining.clamp(1, 65535))), + ); + } + }, + ); + pending.add(timer); + } + + Future _call(Future operation) async { + final epoch = _epoch; + try { + await operation; + } catch (_) { + if (epoch == _epoch) _fail(); + } + } + + void _fail() { + stop(); + failed?.call( + 'Device beep mirroring stopped. Check media volume and enable it again in Settings.', + ); + } + + void stop() { + ++_epoch; + _lease?.cancel(); + _lease = null; + for (final timer in pending) { + timer.cancel(); + } + pending.clear(); + _device = null; + _start = null; + _lastId = 0; + _opening = false; + final session = _session; + _session = null; + if (session != null) { + unawaited(output.stop(session).catchError((Object _) {})); + } + } + + void dispose() { + _enabled = false; + stop(); + clock.stop(); + } +} diff --git a/mobile/lib/unified/channel_editor.dart b/mobile/lib/unified/channel_editor.dart new file mode 100644 index 000000000..fae7f7b3b --- /dev/null +++ b/mobile/lib/unified/channel_editor.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import '../icons.dart'; +import 'transport.dart' show Json; + +const channelIcons = { + 'armchair': Icons.airline_seat_recline_normal, + 'lightbulb': Icons.lightbulb_outline, + 'lightning': PakarIcons.lightning, + 'power': Icons.power_settings_new, + 'fan': Icons.air, + 'speaker': Icons.speaker_outlined, + 'monitor': Icons.monitor, + 'cpu': PakarIcons.cpu, + 'thermometer': Icons.thermostat, + 'gauge': PakarIcons.gauge, + 'music-note': PakarIcons.musicNote, + 'keyboard': PakarIcons.keyboard, + 'radio': PakarIcons.radio, +}; +IconData channelIcon(Json control) => + channelIcons[control['icon']] ?? + (['seat', 'side', 'motion'].contains(control['kind']) + ? Icons.airline_seat_recline_normal + : control['kind'] == 'relay' + ? Icons.power + : Icons.lightbulb_outline); +Color? channelColor(Object? value) => + value is String && RegExp(r'^#[0-9a-fA-F]{6}$').hasMatch(value) + ? Color(0xff000000 | int.parse(value.substring(1), radix: 16)) + : null; + +class ChannelEditor extends StatefulWidget { + const ChannelEditor({super.key, required this.control}); + final Json control; + @override + State createState() => _ChannelEditorState(); +} + +class _ChannelEditorState extends State { + final form = GlobalKey(); + late final name = TextEditingController( + text: '${widget.control['name'] ?? ''}', + ); + late final folder = TextEditingController( + text: '${widget.control['group'] ?? ''}', + ); + late final color = TextEditingController( + text: '${widget.control['color'] ?? ''}', + ); + late String icon = '${widget.control['icon'] ?? ''}'; + @override + void dispose() { + name.dispose(); + folder.dispose(); + color.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Row( + children: [ + Icon( + channelIcons[icon] ?? channelIcon(widget.control), + color: channelColor(color.text), + ), + const SizedBox(width: 10), + const Text('Edit channel'), + ], + ), + content: SizedBox( + width: 440, + child: SingleChildScrollView( + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: name, + maxLength: 96, + decoration: const InputDecoration(labelText: 'Name'), + ), + TextFormField( + controller: folder, + maxLength: 96, + decoration: const InputDecoration(labelText: 'Folder'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: icon, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Icon'), + items: [ + const DropdownMenuItem(value: '', child: Text('Default')), + if (icon.isNotEmpty && !channelIcons.containsKey(icon)) + DropdownMenuItem(value: icon, child: Text(icon)), + for (final entry in channelIcons.entries) + DropdownMenuItem( + value: entry.key, + child: Row( + children: [ + Icon(entry.value, size: 20), + const SizedBox(width: 12), + Text(entry.key), + ], + ), + ), + ], + onChanged: (v) { + if (v != null) setState(() => icon = v); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: color, + decoration: const InputDecoration( + labelText: 'Color', + hintText: '#RRGGBB', + ), + onChanged: (_) => setState(() {}), + validator: (v) => + v != null && v.isNotEmpty && channelColor(v) == null + ? 'Use #RRGGBB or leave blank for Default' + : null, + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final value in [ + '#7166CE', + '#55BD83', + '#E68B85', + '#E0AC62', + '#62BCAE', + '#5D9BDE', + '#BA80D7', + ]) + IconButton( + tooltip: value, + onPressed: () => setState(() => color.text = value), + icon: Icon( + color.text.toUpperCase() == value + ? Icons.check_circle + : Icons.circle, + color: channelColor(value), + ), + style: IconButton.styleFrom( + minimumSize: const Size(44, 44), + ), + ), + ], + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (form.currentState!.validate()) { + Navigator.pop(context, { + 'name': name.text.trim(), + 'group': folder.text.trim(), + 'icon': icon, + 'color': color.text.trim(), + }); + } + }, + child: const Text('Save'), + ), + ], + ); +} diff --git a/mobile/lib/unified/live_widgets.dart b/mobile/lib/unified/live_widgets.dart index 375f64ca0..08f57df53 100644 --- a/mobile/lib/unified/live_widgets.dart +++ b/mobile/lib/unified/live_widgets.dart @@ -23,11 +23,13 @@ class TelemetryHistory { final time = DateTime.tryParse('${snapshot['status_updated']}'); if (snapshot['have_status'] != true || time == null || - snapshot['connected'] != true) + snapshot['connected'] != true) { return; + } if (points.isNotEmpty && - time.difference(points.last.time).inMilliseconds < 500) + time.difference(points.last.time).inMilliseconds < 500) { return; + } final status = object(snapshot['status']); double? read( String key, @@ -125,6 +127,107 @@ class StatePill extends StatelessWidget { } } +class FrontPanelButtons extends StatelessWidget { + const FrontPanelButtons({ + super.key, + required this.captured, + required this.enabled, + required this.dispatch, + this.pressedKeys, + }); + final bool captured, enabled; + final int? pressedKeys; + final ValueChanged dispatch; + @override + Widget build(BuildContext context) => Row( + children: [ + for (var index = 0; index < 4; index++) ...[ + if (index > 0) const SizedBox(width: 6), + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(44, 48), + padding: const EdgeInsets.symmetric(horizontal: 4), + backgroundColor: + pressedKeys != null && pressedKeys! & (1 << index) != 0 + ? Theme.of(context).colorScheme.primaryContainer + : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: enabled + ? () => dispatch( + captured + ? 'host-menu key K${index + 1} press' + : 'menu ${['prev', 'next', 'dec', 'inc'][index]}', + ) + : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!captured) + Icon( + [ + Icons.arrow_back, + Icons.arrow_forward, + Icons.remove, + Icons.add, + ][index], + size: 18, + ), + Text( + captured + ? 'K${index + 1}' + : ['Prev', 'Next', '−', '+'][index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ], + ], + ); +} + +/// Keep a user's drag preview stable while live host snapshots continue. Only +/// release sends a request; the owner-bearing key retires a drag on peer change. +class RemoteSlider extends StatefulWidget { + const RemoteSlider({ + super.key, + required this.value, + required this.maximum, + required this.changed, + this.enabled = true, + this.label, + }); + final double value, maximum; + final bool enabled; + final ValueChanged changed; + final String Function(double)? label; + @override + State createState() => _RemoteSliderState(); +} + +class _RemoteSliderState extends State { + double? draft; + @override + Widget build(BuildContext context) => Slider( + value: (draft ?? widget.value).clamp(0, widget.maximum), + max: widget.maximum, + label: widget.label?.call(draft ?? widget.value), + onChanged: widget.enabled ? (v) => setState(() => draft = v) : null, + onChangeEnd: widget.enabled + ? (v) { + setState(() => draft = null); + widget.changed(v); + } + : null, + ); +} + class ChannelObservation { const ChannelObservation(this.label, this.active, {this.percent}); final String label; @@ -138,8 +241,9 @@ ChannelObservation observeChannel( Json pwm, bool fresh, ) { - if (!fresh || snapshot['have_status'] != true) + if (!fresh || snapshot['have_status'] != true) { return const ChannelObservation('Unknown', null); + } final key = '${control['key']}', status = object(snapshot['status']); if (key.startsWith('relay.')) { final id = int.tryParse(key.split('.').last), @@ -213,7 +317,7 @@ class TelemetryChart extends StatelessWidget { .map((p) => p.values[field]) .whereType() .toList(); - if (numbers.isEmpty) + if (numbers.isEmpty) { return SizedBox( height: height, child: Center( @@ -226,6 +330,7 @@ class TelemetryChart extends StatelessWidget { ), ), ); + } final low = numbers.reduce(math.min), high = numbers.reduce(math.max); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -278,7 +383,7 @@ class _ChartPainter extends CustomPainter { .whereType() .toList(); if (values.isEmpty) return; - for (var row = 0; row < 3; row++) + for (var row = 0; row < 3; row++) { canvas.drawLine( Offset(0, 4 + row * (size.height - 8) / 2), Offset(size.width, 4 + row * (size.height - 8) / 2), @@ -286,6 +391,7 @@ class _ChartPainter extends CustomPainter { ..color = grid.withValues(alpha: .3) ..strokeWidth = 1, ); + } final min = values.reduce(math.min), max = values.reduce(math.max); final padding = math.max( (max - min) * .15, @@ -311,12 +417,14 @@ class _ChartPainter extends CustomPainter { size.height - 4 - (value - low) / span * (size.height - 8), ); if (previousTime != null && - point.time.difference(previousTime).inMilliseconds > 3000) + point.time.difference(previousTime).inMilliseconds > 3000) { joined = false; - if (joined) + } + if (joined) { path.lineTo(position.dx, position.dy); - else + } else { path.moveTo(position.dx, position.dy); + } joined = true; last = position; previousTime = point.time; diff --git a/mobile/lib/unified/preferences.dart b/mobile/lib/unified/preferences.dart new file mode 100644 index 000000000..307811a99 --- /dev/null +++ b/mobile/lib/unified/preferences.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPreferences extends ChangeNotifier { + void refresh() => notifyListeners(); + ThemeMode theme = ThemeMode.system; + String layout = 'auto'; + bool compact = false; + bool mirrorBuzzer = false; + double mirrorVolume = .35; + Set launchers = {'pccontroller'}; + static const native = MethodChannel( + 'ir.digitalogic.pccontroller/preferences', + ); + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + theme = ThemeMode.values.firstWhere( + (v) => v.name == prefs.getString('appearance.theme'), + orElse: () => ThemeMode.system, + ); + final savedLayout = prefs.getString('appearance.layout'); + if (['auto', 'phone', 'tablet'].contains(savedLayout)) { + layout = savedLayout!; + } + compact = prefs.getBool('appearance.compact') ?? false; + mirrorBuzzer = prefs.getBool('mirror.buzzer') ?? false; + mirrorVolume = (prefs.getDouble('mirror.volume') ?? .35).clamp(0, 1); + // Android owns the actual launcher state, including after an app update. + try { + final actual = await native.invokeListMethod('launchers'); + if (actual != null && actual.isNotEmpty) launchers = actual.toSet(); + } on PlatformException { + // Optional OS preferences must not block saved peer restoration. + } on MissingPluginException { + // Renderer/non-Android surfaces have no Android launcher adapter. + } + notifyListeners(); + } + + Future save() async { + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('appearance.theme', theme.name); + await prefs.setString('appearance.layout', layout); + await prefs.setBool('appearance.compact', compact); + await prefs.setBool('mirror.buzzer', mirrorBuzzer); + await prefs.setDouble('mirror.volume', mirrorVolume); + } + + Future setLaunchers(Set values) async { + if (values.isEmpty) return; + await native.invokeMethod('setLaunchers', { + 'values': values.toList(), + }); + launchers = Set.of(values); + notifyListeners(); + } +} + +class AppSettings extends StatelessWidget { + const AppSettings({ + super.key, + required this.preferences, + required this.connections, + required this.automation, + required this.changed, + }); + final AppPreferences preferences; + final Widget connections, automation; + final VoidCallback changed; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: preferences, + builder: (context, _) => Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _section(context, 'Appearance', Icons.palette_outlined, [ + DropdownButtonFormField( + initialValue: preferences.theme, + decoration: const InputDecoration(labelText: 'Theme'), + items: [ + for (final mode in ThemeMode.values) + DropdownMenuItem( + value: mode, + child: Text(switch (mode) { + ThemeMode.system => 'System', + ThemeMode.light => 'Light', + ThemeMode.dark => 'Dark', + }), + ), + ], + onChanged: (v) { + if (v != null) { + preferences.theme = v; + preferences.save(); + } + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: preferences.layout, + decoration: const InputDecoration(labelText: 'Layout'), + items: const [ + DropdownMenuItem(value: 'auto', child: Text('Automatic')), + DropdownMenuItem(value: 'phone', child: Text('Mobile')), + DropdownMenuItem(value: 'tablet', child: Text('Tablet')), + ], + onChanged: (v) { + if (v != null) { + preferences.layout = v; + preferences.save(); + } + }, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Compact controls'), + value: preferences.compact, + onChanged: (v) { + preferences.compact = v; + preferences.save(); + }, + ), + ]), + _section(context, 'Launcher icons', Icons.apps, [ + for (final entry in [ + ('pccontroller', 'PCController'), + ('pealayer', 'Pealayer'), + ]) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + secondary: Image.asset( + 'assets/branding/${entry.$1}.png', + width: 36, + height: 36, + ), + title: Text(entry.$2), + value: preferences.launchers.contains(entry.$1), + onChanged: (v) async { + final next = Set.of(preferences.launchers); + if (v == true) { + next.add(entry.$1); + } else { + next.remove(entry.$1); + } + if (next.isEmpty) return; + try { + await preferences.setLaunchers(next); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Launcher change failed. Try again.'), + ), + ); + } + } + }, + ), + const Text('Select one icon or keep both mini-app launchers.'), + ]), + _section(context, 'Mirroring', Icons.speaker_outlined, [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Mirror board beeps on this device'), + value: preferences.mirrorBuzzer, + onChanged: (v) { + preferences.mirrorBuzzer = v; + preferences.save(); + changed(); + }, + ), + Row( + children: [ + const Icon(Icons.volume_down_outlined), + Expanded( + child: Slider( + value: preferences.mirrorVolume, + label: '${(preferences.mirrorVolume * 100).round()}%', + onChanged: (v) { + preferences.mirrorVolume = v; + preferences.refresh(); + }, + onChangeEnd: (_) { + preferences.save(); + changed(); + }, + ), + ), + Text('${(preferences.mirrorVolume * 100).round()}%'), + ], + ), + ]), + connections, + const SizedBox(height: 16), + automation, + ], + ), + ), + ); + Widget _section( + BuildContext context, + String title, + IconData icon, + List children, + ) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 22), + const SizedBox(width: 10), + Text(title, style: Theme.of(context).textTheme.titleMedium), + ], + ), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ), + ); +} diff --git a/mobile/lib/unified/transport.dart b/mobile/lib/unified/transport.dart index 3db4ff131..79ffc0ca1 100644 --- a/mobile/lib/unified/transport.dart +++ b/mobile/lib/unified/transport.dart @@ -313,6 +313,7 @@ String operationLabel(Json value) { class PeerSession extends ChangeNotifier { PeerSession({required this.controller}); final bool controller; + void Function(Json)? onBuzzerNote; PeerTransport? transport; WebSocket? _socket; Timer? _retry, _freshness; @@ -615,14 +616,17 @@ class PeerSession extends ChangeNotifier { online = true; error = null; } - if (event['kind'] == 'status_led.changed') + if (event['kind'] == 'status_led.changed') { _schedulePwmRefresh(epoch); + } notifyListeners(); return; // Frames never trigger full snapshot/catalog polling. } - if (event['kind'] != 'buzzer.note') { - events = [event, ...events].take(100).toList(); + if (event['kind'] == 'buzzer.note') { + onBuzzerNote?.call(event); + return; // Audio frames must not poll snapshot/catalog per note. } + events = [event, ...events].take(100).toList(); if ([ 'pwm.changed', 'pwm', @@ -668,8 +672,9 @@ class PeerSession extends ChangeNotifier { if (fresh && state['connected'] == true && (pwmUpdated == null || - DateTime.now().difference(pwmUpdated!).inMilliseconds >= 2000)) + DateTime.now().difference(pwmUpdated!).inMilliseconds >= 2000)) { unawaited(refreshPwm()); + } // Only repaint when freshness expires, not continuous UI persistence. if (!fresh && online) { online = false; @@ -699,8 +704,9 @@ class PeerSession extends ChangeNotifier { if (!controller || peer == null || _pwmReading || - state['connected'] != true) + state['connected'] != true) { return; + } _pwmReading = true; try { final value = object(await peer.rpc('controller.pwm.values')); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 166ea0632..068d64ec1 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.1+2 +version: 0.1.2+3 environment: sdk: ^3.13.3 diff --git a/mobile/test/unified_acceptance_test.dart b/mobile/test/unified_acceptance_test.dart new file mode 100644 index 000000000..a4c301a22 --- /dev/null +++ b/mobile/test/unified_acceptance_test.dart @@ -0,0 +1,283 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pccontroller_mobile/main.dart'; +import 'package:pccontroller_mobile/unified/transport.dart'; +import 'package:pccontroller_mobile/unified/live_widgets.dart'; +import 'package:pccontroller_mobile/unified/automation.dart'; + +void main() { + testWidgets( + 'live slider keeps drag preview and sends one request only on release', + (tester) async { + final values = []; + Widget screen(double value) => MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: RemoteSlider( + key: const ValueKey('same-owner'), + value: value, + maximum: 100, + changed: values.add, + ), + ), + ), + ), + ); + await tester.pumpWidget(screen(10)); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + final dragging = tester.widget(find.byType(Slider)).value; + expect(values, isEmpty); + await tester.pumpWidget(screen(20)); + expect(tester.widget(find.byType(Slider)).value, dragging); + await gesture.up(); + await tester.pump(); + expect(values, hasLength(1)); + expect(values.single, dragging); + await tester.pumpWidget(const SizedBox()); + }, + ); + for (final size in [const Size(360, 800), const Size(1280, 800)]) { + testWidgets( + 'Pealayer advertises actual workspaces and media options at $size', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const UnifiedControllerApp(restore: false)); + final dynamic home = tester.state(find.byType(ControllerHome)); + final PeerSession peer = home.player; + peer.online = true; + peer.received = DateTime.now(); + peer.state = { + 'playing': false, + 'volume': 120.0, + 'muted': true, + 'playback_rate': 1.0, + 'current_video': 'fixture.mp4', + 'seekable': true, + 'duration': 120.0, + 'playback_time': 10.0, + 'media_session_capabilities': { + 'can_play': true, + 'can_pause': false, + 'can_stop': true, + 'can_next': false, + 'can_previous': false, + }, + 'active_workspace_profile': 'custom', + 'workspace_profiles': [ + {'id': 'custom', 'name': 'My workspace'}, + ], + 'chapters': [ + {'index': 0, 'title': 'Opening', 'time_seconds': 0.0}, + ], + 'current_chapter_index': 0, + 'media_tracks': [ + { + 'id': 1, + 'kind': 'audio', + 'title': 'Persian audio', + 'selected': true, + }, + { + 'id': 2, + 'kind': 'sub', + 'title': 'English subtitles', + 'selected': false, + }, + ], + }; + home.section = 1; + peer.notifyListeners(); + await tester.pumpAndSettle(); + expect(find.byTooltip('Play'), findsOneWidget); + expect(find.byTooltip('Pause'), findsNothing); + expect(find.text('Unmute'), findsOneWidget); + expect(find.text('My workspace'), findsOneWidget); + expect(find.text('Audio track'), findsOneWidget); + expect(find.text('Subtitles'), findsOneWidget); + expect(tester.takeException(), null); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + testWidgets( + 'local panel API validates current mode, bounds and E-STOP before dispatch', + (tester) async { + await tester.pumpWidget(const UnifiedControllerApp(restore: false)); + final dynamic home = tester.state(find.byType(ControllerHome)); + final PeerSession peer = home.board; + final requests = []; + peer.transport = PeerTransport( + 'http://fixture', + client: MockClient((request) async { + final body = object(jsonDecode(request.body)); + requests.add(body); + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': body['id'], + 'result': {'accepted': true}, + }), + 200, + ); + }), + ); + peer.online = true; + peer.received = DateTime.now(); + peer.state = { + 'host_instance_id': 'fixture', + 'connection_generation': 7, + 'connected': true, + 'have_front_panel': true, + 'front_panel': {'host_captured': false, 'lcd_available': false}, + }; + home.automationActuation = true; + final LocalAutomation api = home.automation; + final target = {'expected_target': peer.targetStamp}; + for (final call in [ + ('board.menu.press', {'button': 'K1'}), + ('board.display.show', {'display': 'lcd', 'text': 'test'}), + ('board.buzzer.play', {'frequency_hz': 880, 'duration_ms': 1001}), + ('board.mode.set', {'mode': 'invalid'}), + ]) { + await expectLater( + api.dispatch(call.$1, {...target, ...call.$2}), + throwsA(isA()), + ); + } + expect(requests, isEmpty); + await tester.runAsync( + () => api.dispatch('board.buzzer.play', { + ...target, + 'frequency_hz': 880, + 'duration_ms': 115, + }), + ); + expect(requests.single['params'], {'command': 'buzzer 880 115'}); + peer.state['emergency_stop'] = {'active': true}; + await expectLater( + api.dispatch('board.menu.press', {...target, 'button': 'prev'}), + throwsA(isA()), + ); + expect(requests, hasLength(1)); + await tester.runAsync(() => api.dispatch('board.buzzer.stop', target)); + expect(requests.last['params'], {'command': 'melody stop'}); + expect(requests, hasLength(2)); + await tester.pumpWidget(const SizedBox()); + }, + ); + for (final size in [const Size(360, 800), const Size(1280, 800)]) { + testWidgets( + 'channel indicator shares title row and management menu at $size', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const UnifiedControllerApp(restore: false)); + final dynamic home = tester.state(find.byType(ControllerHome)); + final PeerSession peer = home.board; + peer.online = true; + peer.received = DateTime.now(); + peer.state = { + 'host_instance_id': 'fixture', + 'connection_generation': 7, + 'connected': true, + 'have_status': true, + 'status_updated': DateTime.now().toIso8601String(), + 'status': {'active_relays': 16}, + }; + peer.peripherals = { + 'board_profile': { + 'attached': true, + 'configured': true, + 'revision': 'fixture', + }, + 'controls': [ + { + 'key': 'relay.5', + 'kind': 'relay', + 'control': 'relay', + 'name': 'Lobby lighting', + 'actions': [], + }, + ], + }; + home.boardTab = 1; + peer.notifyListeners(); + await tester.pumpAndSettle(); + final title = find.text('Lobby lighting'), indicator = find.text('On'); + expect(indicator, findsOneWidget); + expect( + (tester.getCenter(title).dy - tester.getCenter(indicator).dy).abs(), + lessThan(3), + ); + expect( + tester.getCenter(indicator).dx, + greaterThan(tester.getCenter(title).dx), + ); + await tester.tap(find.byTooltip('Manage Lobby lighting')); + await tester.pumpAndSettle(); + expect(find.text('Edit channel'), findsOneWidget); + expect(find.text('Lock channel'), findsOneWidget); + expect(tester.takeException(), null); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + for (final captured in [false, true]) { + testWidgets( + 'one front-panel row dispatches only current mode, captured=$captured', + (tester) async { + final commands = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 288, + child: FrontPanelButtons( + captured: captured, + enabled: true, + dispatch: commands.add, + ), + ), + ), + ), + ); + expect(find.byType(OutlinedButton), findsNWidgets(4)); + final positions = [ + for (final e in find.byType(OutlinedButton).evaluate()) + tester.getCenter(find.byWidget(e.widget)).dy, + ]; + expect(positions.toSet().length, 1); + await tester.tap(find.byType(OutlinedButton).first); + expect(commands, [captured ? 'host-menu key K1 press' : 'menu prev']); + expect(tester.takeException(), null); + }, + ); + } + testWidgets('settings are reachable without a connected host', ( + tester, + ) async { + await tester.pumpWidget(const UnifiedControllerApp(restore: false)); + await tester.tap(find.byTooltip('Settings')); + await tester.pumpAndSettle(); + expect(find.text('Appearance'), findsOneWidget); + expect(find.text('Launcher icons'), findsOneWidget); + expect(find.text('Mirror board beeps on this device'), findsOneWidget); + expect(tester.takeException(), null); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/mobile/test/unified_buzzer_test.dart b/mobile/test/unified_buzzer_test.dart new file mode 100644 index 000000000..e37e14046 --- /dev/null +++ b/mobile/test/unified_buzzer_test.dart @@ -0,0 +1,113 @@ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pccontroller_mobile/mirroring.dart'; +import 'package:pccontroller_mobile/unified/buzzer_mirror.dart'; + +class RecordingAudio implements ToneOutput { + final notes = <(int, int)>[]; + final stops = []; + int opens = 0; + @override + Future open(String session, double volume) async { + opens++; + } + + @override + Future note(String session, int frequency, int durationMs) async { + notes.add((frequency, durationMs)); + } + + @override + Future lease(String session) async {} + @override + Future stop(String session) async { + stops.add(session); + } +} + +Map note( + int id, { + int hz = 880, + int ms = 115, + int generation = 7, +}) => { + 'id': id, + 'kind': 'buzzer.note', + 'metadata': { + 'frequency_hz': '$hz', + 'duration_ms': '$ms', + 'connection_generation': '$generation', + 'device_micros': '${id * 200000}', + }, +}; + +void main() { + test('actual note contract reaches finite native PCM once; Stop cancels pending notes', () async { + final audio = RecordingAudio(); + final receiver = BuzzerMirror(output: audio); + addTearDown(receiver.dispose); + receiver.configure( + owner: 'selected-host', + generation: 7, + enabled: true, + volume: .35, + ); + await receiver.event(note(1)); + await Future.delayed(const Duration(milliseconds: 20)); + expect(audio.opens, 1); + expect(audio.notes.single.$1, 880); + expect(audio.notes.single.$2, inInclusiveRange(1, 115)); + await receiver.event(note(1)); + expect(audio.notes.length, 1); + await receiver.event(note(2)); + await receiver.event(note(3, hz: 0, ms: 0)); + await Future.delayed(const Duration(milliseconds: 30)); + expect(audio.notes.last, (0, 0)); + expect(audio.notes.where((n) => n.$1 == 880).length, 1); + }); + test( + 'wrong generation, bridge source and malformed notes do not open audio', + () async { + final audio = RecordingAudio(); + final mirror = BuzzerMirror(output: audio); + addTearDown(mirror.dispose); + mirror.configure( + owner: 'selected-host', + generation: 7, + enabled: true, + volume: .35, + ); + await mirror.event(note(1, generation: 6)); + final bridged = note(2); + (bridged['metadata'] as Map)['bridge.ingress'] = 'another-board'; + await mirror.event(bridged); + await mirror.event(note(3, hz: 40000)); + await mirror.event(note(4, ms: 0)); + expect(audio.opens, 0); + }, + ); + test('disconnect cancels queued audio and new owner cannot replay old generation', () async { + final audio = RecordingAudio(); + final mirror = BuzzerMirror(output: audio); + addTearDown(mirror.dispose); + mirror.configure( + owner: 'selected-host', + generation: 7, + enabled: true, + volume: .35, + ); + await mirror.event(note(1)); + mirror.configure(owner: '', generation: null, enabled: false, volume: .35); + await Future.delayed(const Duration(milliseconds: 20)); + expect(audio.notes, isEmpty); + expect(audio.stops, hasLength(1)); + mirror.configure( + owner: 'new-host', + generation: 8, + enabled: true, + volume: .35, + ); + await mirror.event(note(2)); + expect(audio.opens, 1); + }); +} diff --git a/mobile/test/unified_capture_test.dart b/mobile/test/unified_capture_test.dart index 12960081f..0e5165e6b 100644 --- a/mobile/test/unified_capture_test.dart +++ b/mobile/test/unified_capture_test.dart @@ -24,6 +24,9 @@ void main() { final icons = FontLoader('MaterialIcons') ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); await icons.load(); + final phosphor = FontLoader('Phosphor') + ..addFont(rootBundle.load('assets/fonts/Phosphor.ttf')); + await phosphor.load(); await tester.runAsync(() async { await rootBundle.load('assets/branding/pccontroller.png'); await rootBundle.load('assets/branding/pealayer.png'); @@ -129,6 +132,59 @@ void main() { }); tester.platformDispatcher.clearPlatformBrightnessTestValue(); } + final dynamic home = tester.state(find.byType(ControllerHome)); + home.openSettings(); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + final frame = await render.toImage(); + final bytes = await frame.toByteData(format: ui.ImageByteFormat.png); + await File('$directory/settings-${size.width.toInt()}.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + frame.dispose(); + }); + await tester.pageBack(); + await tester.pumpAndSettle(); + final PeerSession player = home.player; + // Current-contract memory fixture; never a real media/output request. + player.state = { + 'playing': false, + 'volume': 80.0, + 'muted': false, + 'playback_time': 14.0, + 'duration': 120.0, + 'seekable': true, + 'current_video': 'Preview media', + 'playback_rate': 1.0, + 'media_session_capabilities': {'can_play': true, 'can_stop': true}, + 'workspace_profiles': [ + {'id': 'fixture', 'name': 'Editing workspace'}, + ], + 'active_workspace_profile': 'fixture', + 'chapters': [ + {'index': 0, 'title': 'Opening', 'time_seconds': 0.0}, + ], + 'current_chapter_index': 0, + 'media_tracks': [ + {'id': 1, 'kind': 'audio', 'title': 'Main audio', 'selected': true}, + {'id': 2, 'kind': 'sub', 'title': 'Subtitles', 'selected': false}, + ], + }; + player.online = true; + player.received = DateTime.now(); + home.section = 1; + player.notifyListeners(); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await precacheImage( + const AssetImage('assets/branding/pealayer.png'), + boundary.currentContext!, + ); + final frame = await render.toImage(); + final bytes = await frame.toByteData(format: ui.ImageByteFormat.png); + await File('$directory/pealayer-${size.width.toInt()}.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + frame.dispose(); + }); expect(tester.takeException(), null); await tester.pumpWidget(const SizedBox()); await tester.pumpWidget( From 2afac3219150a0b1aa36d918f91a93ec718a73a8 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 01:55:27 +0330 Subject: [PATCH 10/72] docs(mobile): retain launcher artwork provenance and current peer contracts --- REUSE.toml | 2 +- mobile/PROVENANCE.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/REUSE.toml b/REUSE.toml index 7f97c4294..e9b258d81 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -53,7 +53,7 @@ SPDX-FileCopyrightText = ["2026 AtomicDeploy contributors", "2026 David Refoua a SPDX-License-Identifier = "MIT" [[annotations]] -path = ["mobile/assets/branding/pealayer.png", "mobile/PEALAYER-LICENSE"] +path = ["mobile/assets/branding/pealayer.png", "mobile/android/app/src/main/res/drawable-nodpi/pealayer_application.png", "mobile/PEALAYER-LICENSE"] precedence = "override" SPDX-FileCopyrightText = "2026 ToghrolTP" SPDX-License-Identifier = "MIT" diff --git a/mobile/PROVENANCE.md b/mobile/PROVENANCE.md index 5ca72b36a..ce8b3aa4f 100644 --- a/mobile/PROVENANCE.md +++ b/mobile/PROVENANCE.md @@ -22,6 +22,13 @@ `f3ab0659028944986e90759973f06e634b57b4ac8964fa332659fc44c3c66f35`. - Public Pealayer icon SHA-256: `082cd912ba2a649f4f2c975b570efa6063e1c96a8051e0b81cdd1895826ab9ca`. + The Android Pealayer launcher uses an unchanged copy of this same public + asset; its upstream copyright/license annotation is retained. +- Preview 0.1.2's Pealayer controls were checked against current source + `d42cfeb8d08d1c662aea9683f27cca326bdd9b39`: media-session capability fields, + workspace profiles, chapter/track selection and the 0..130 volume domain. + Buzzer scheduling follows PCController's current `buzzer.note` metadata and + wrapping-clock timeline policy, using the retained Android PCM adapter. MIT notices are preserved as PAKAR-LICENSE and PEALAYER-LICENSE. Font notices remain under assets/fonts. No private Soulayer/user artworks were copied. From b36af2ca796d0a58c1c5b957ae65a453b8c91499 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 01:56:58 +0330 Subject: [PATCH 11/72] test(mobile): await branding decode in settings capture --- mobile/test/unified_capture_test.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/test/unified_capture_test.dart b/mobile/test/unified_capture_test.dart index 0e5165e6b..3f6ebf684 100644 --- a/mobile/test/unified_capture_test.dart +++ b/mobile/test/unified_capture_test.dart @@ -135,6 +135,13 @@ void main() { final dynamic home = tester.state(find.byType(ControllerHome)); home.openSettings(); await tester.pumpAndSettle(); + await tester.runAsync(() async { + await precacheImage( + const AssetImage('assets/branding/pealayer.png'), + boundary.currentContext!, + ); + }); + await tester.pumpAndSettle(); await tester.runAsync(() async { final frame = await render.toImage(); final bytes = await frame.toByteData(format: ui.ImageByteFormat.png); From 3697520549818c97175a4cbac88d38d5026dc746 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 02:21:10 +0330 Subject: [PATCH 12/72] feat: add binary UDP RPC, discovery and leased events [skip ci] --- Tools/Controller/examples/udp-peer/main.go | 69 +++ Tools/Controller/host/host.go | 1 + Tools/Controller/internal/appconfig/config.go | 26 +- Tools/Controller/internal/ipcjson/ipc.go | 15 + Tools/Controller/internal/ipcjson/udp.go | 217 +++++++ Tools/Controller/rpc/client.go | 74 +++ Tools/Controller/rpc/types.go | 3 +- Tools/Controller/transport/udp/codec.go | 265 ++++++++ Tools/Controller/transport/udp/endpoint.go | 586 ++++++++++++++++++ .../Controller/web/src/advanced-workbench.tsx | 2 + Tools/Controller/web/src/udp-settings.tsx | 51 ++ docs/BINARY-UDP.md | 153 +++++ 12 files changed, 1453 insertions(+), 9 deletions(-) create mode 100644 Tools/Controller/examples/udp-peer/main.go create mode 100644 Tools/Controller/internal/ipcjson/udp.go create mode 100644 Tools/Controller/transport/udp/codec.go create mode 100644 Tools/Controller/transport/udp/endpoint.go create mode 100644 Tools/Controller/web/src/udp-settings.tsx create mode 100644 docs/BINARY-UDP.md diff --git a/Tools/Controller/examples/udp-peer/main.go b/Tools/Controller/examples/udp-peer/main.go new file mode 100644 index 000000000..5abb21f27 --- /dev/null +++ b/Tools/Controller/examples/udp-peer/main.go @@ -0,0 +1,69 @@ +// udp-peer is an isolated, loopback-only developer probe. It never connects a +// board or starts host integrations, and never reads the user's configuration. +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "pccontroller.local/controller/host" + "pccontroller.local/controller/internal/appconfig" + udp "pccontroller.local/controller/transport/udp" + "time" +) + +func main() { + if len(os.Args) == 2 && os.Args[1] == "--codec" { + b, err := udp.Marshal(map[string]any{"signed": int64(-9223372036854775808), "unsigned": uint64(18446744073709551615), "float": 0.125, "unicode": "همتا", "array": []any{nil, false, true}}) + must(err) + fmt.Println(hex.EncodeToString(b)) + return + } + root, err := os.MkdirTemp("", "pccontroller-udp-probe-") + must(err) + defer os.RemoveAll(root) + path := filepath.Join(root, "config.json") + store, err := appconfig.Open(path) + must(err) + _, err = store.Update(func(c *appconfig.Config) error { + c.IPC.UDP = udp.Config{Enabled: true, Listen: "127.0.0.1:18877"} + return nil + }) + must(err) + h, err := host.New(host.Options{ConfigPath: path, DataRoot: root, DisableAutoConnect: true, DisableNative: true, HTTP: &host.HTTPOptions{Address: "127.0.0.1:18878"}}) + must(err) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + must(h.Start(ctx)) + defer h.Stop(context.Background()) + client, err := h.Controller() + must(err) + for i := 0; i < 30; i++ { + var status map[string]any + must(h.Call(ctx, "controller.udp.status", map[string]any{}, &status)) + if status["running"] == true { + b, _ := json.Marshal(status) + fmt.Println(string(b)) + break + } + time.Sleep(100 * time.Millisecond) + } + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + client.EmitHostEvent("udp_probe", "Isolated loopback event; no board connected") + } + } +} +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/Tools/Controller/host/host.go b/Tools/Controller/host/host.go index 3b601fdde..0df1c4da4 100644 --- a/Tools/Controller/host/host.go +++ b/Tools/Controller/host/host.go @@ -358,6 +358,7 @@ func (host *Host) start(parent context.Context) error { } return manager.BridgePeers() } + service.StartUDP(ctx) service.BridgeCall = func(callContext context.Context, peer string, request ipcjson.Request) (ipcjson.Response, error) { host.mu.RLock() manager := host.integrations diff --git a/Tools/Controller/internal/appconfig/config.go b/Tools/Controller/internal/appconfig/config.go index 9e45c3fe7..7d2448440 100644 --- a/Tools/Controller/internal/appconfig/config.go +++ b/Tools/Controller/internal/appconfig/config.go @@ -28,6 +28,7 @@ import ( "pccontroller.local/controller/internal/hostos" "pccontroller.local/controller/internal/productidentity" "pccontroller.local/controller/internal/secretstore" + udptransport "pccontroller.local/controller/transport/udp" ) const ( @@ -175,14 +176,15 @@ func NormalizeAppearance(value Appearance) Appearance { // IPC configures authenticated local and optional remote controller transports. type IPC struct { - Listen string `json:"listen"` - WebSocketPath string `json:"websocket_path"` - AllowRemote bool `json:"allow_remote"` - AuthToken string `json:"auth_token,omitempty"` - AuthTokenRef string `json:"auth_token_ref,omitempty"` - RemotePrincipal string `json:"remote_principal"` - AllowedOrigins []string `json:"allowed_origins,omitempty"` - RemotePolicy RemoteAccessPolicy `json:"remote_policy"` + UDP udptransport.Config `json:"udp"` + Listen string `json:"listen"` + WebSocketPath string `json:"websocket_path"` + AllowRemote bool `json:"allow_remote"` + AuthToken string `json:"auth_token,omitempty"` + AuthTokenRef string `json:"auth_token_ref,omitempty"` + RemotePrincipal string `json:"remote_principal"` + AllowedOrigins []string `json:"allowed_origins,omitempty"` + RemotePolicy RemoteAccessPolicy `json:"remote_policy"` // Socket.IO is a distinct protocol and is never advertised by the plain // WebSocket endpoint. This path is reserved for an explicit adapter. SocketIOPath string `json:"socket_io_path"` @@ -480,6 +482,7 @@ func Defaults() Config { SegmentScroll: DefaultSegmentScroll(), }, IPC: IPC{ + UDP: udptransport.Config{Listen: "127.0.0.1:8787"}, Listen: "127.0.0.1:8787", WebSocketPath: "/ipc", RemotePrincipal: "remote-operator", @@ -719,6 +722,9 @@ func normalizeMacros(macros []Macro, sourceSchema int) { // Validate rejects unsafe, ambiguous, or unsupported host configuration values. func (value Config) Validate() error { + if err := value.IPC.UDP.Validate(); err != nil { + return err + } if _, err := deployment.Normalize(value.Programming.Deployment); err != nil { return fmt.Errorf("programming.deployment: %w", err) } @@ -1951,6 +1957,10 @@ func clone(value Config) Config { value.StatusEffects..., ) copyValue.IPC.AllowedOrigins = append([]string(nil), value.IPC.AllowedOrigins...) + copyValue.IPC.UDP.Peers = make(map[string]string, len(value.IPC.UDP.Peers)) + for name, address := range value.IPC.UDP.Peers { + copyValue.IPC.UDP.Peers[name] = address + } copyValue.Integrations = cloneIntegrations(value.Integrations) copyValue.RF = cloneRFConfig(value.RF) copyValue.HostMenus = cloneHostMenus(value.HostMenus) diff --git a/Tools/Controller/internal/ipcjson/ipc.go b/Tools/Controller/internal/ipcjson/ipc.go index 536802e70..1f3ecff9e 100644 --- a/Tools/Controller/internal/ipcjson/ipc.go +++ b/Tools/Controller/internal/ipcjson/ipc.go @@ -227,6 +227,8 @@ const ( ) type Service struct { + udpOnce sync.Once + udpState udpRuntime Client *controller.Client WebSocketPath string SocketIOPath string @@ -538,6 +540,9 @@ func (service *Service) dispatch( response.Error = &RPCError{Code: -32003, Message: err.Error()} return response } + if strings.HasPrefix(request.Method, "controller.udp.") { + return service.dispatchUDP(ctx, request, access) + } // Primary discovery must not queue behind a long board, firmware, or shell if request.ClientID != "" { if service.AppInstances == nil { @@ -2423,6 +2428,14 @@ func requestCapability(method string, params json.RawMessage) string { } method = strings.ToLower(strings.TrimSpace(method)) switch method { + case "controller.udp.config.get", "controller.udp.status": + return capabilityRead + case "controller.udp.events.get", "controller.udp.subscribe", "controller.udp.unsubscribe": + return capabilityEvents + case "controller.udp.config.set": + return capabilityHostConfig + case "controller.udp.call": + return capabilityBridgeCalls case "controller.event.next", "controller.event.latest", "controller.subscribe", "controller.unsubscribe": return capabilityEvents @@ -2751,6 +2764,7 @@ func (service *Service) powerAction( } func Serve(ctx context.Context, listener net.Listener, service *Service) error { + service.StartUDP(ctx) var wait sync.WaitGroup websocketListener := newDispatchListener(listener.Addr()) websocketServer := &http.Server{ @@ -5148,6 +5162,7 @@ func serveStreams( service *Service, access Access, ) error { + service.StartUDP(ctx) scanner := bufio.NewScanner(input) scanner.Buffer(make([]byte, 4096), maxMessage) encoder := json.NewEncoder(output) diff --git a/Tools/Controller/internal/ipcjson/udp.go b/Tools/Controller/internal/ipcjson/udp.go new file mode 100644 index 000000000..0d09221d7 --- /dev/null +++ b/Tools/Controller/internal/ipcjson/udp.go @@ -0,0 +1,217 @@ +package ipcjson + +import ( + "context" + "encoding/json" + "errors" + "net" + "reflect" + "sync" + "time" + + "pccontroller.local/controller/internal/appconfig" + udp "pccontroller.local/controller/transport/udp" +) + +type udpRuntime struct { + sync.Mutex + endpoint *udp.Endpoint + failure string +} + +// StartUDP owns one configurable endpoint per dispatcher, independent of the +// HTTP/native listeners. Configuration notifications revoke the previous socket. +func (s *Service) StartUDP(ctx context.Context) { + s.udpOnce.Do(func() { + go func() { + var updates <-chan appconfig.Config + if s.SubscribeHostConfig != nil { + updates = s.SubscribeHostConfig(ctx) + } + current := s.hostConfig() + var applied *udp.Config + var allowRemote bool + var cancel context.CancelFunc + defer func() { + if cancel != nil { + cancel() + } + }() + for { + config := current.IPC.UDP + if applied == nil || !reflect.DeepEqual(*applied, config) || allowRemote != current.IPC.AllowRemote { + allowRemote = current.IPC.AllowRemote + if cancel != nil { + cancel() + } + s.udpState.Lock() + if s.udpState.endpoint != nil { + s.udpState.endpoint.Close() + } + s.udpState.endpoint = nil + s.udpState.failure = "" + s.udpState.Unlock() + copy := config + applied = © + if config.Enabled { + address, err := net.ResolveUDPAddr("udp4", config.Listen) + if err == nil && !address.IP.IsLoopback() && !current.IPC.AllowRemote { + err = errors.New("UDP LAN binding requires ipc.allow_remote") + } + if err == nil { + var ep *udp.Endpoint + endpointContext, stop := context.WithCancel(ctx) + cancel = stop + ep, err = udp.Start(endpointContext, config, map[string]any{"app": "pccontroller", "instance": s.HostInstanceID}, func(callContext context.Context, kind byte, value any, from *net.UDPAddr) any { + access := accessFromAddress(from, "udp") + if kind == udp.Subscribe || kind == udp.Unsubscribe { + if err := s.authorizeCapability(access, "controller.subscribe", capabilityEvents); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"subscribed": kind == udp.Subscribe, "lease_ms": 30000} + } + encoded, _ := json.Marshal(value) + var request Request + if json.Unmarshal(encoded, &request) != nil { + return Response{JSONRPC: Version, Error: &RPCError{Code: -32700, Message: "invalid binary RPC"}} + } + return s.dispatch(callContext, request, access) + }) + if err == nil { + s.udpState.Lock() + s.udpState.endpoint = ep + s.udpState.Unlock() + if s.Client != nil { + go s.publishUDPEvents(endpointContext, ep) + } + } + } + if err != nil { + s.udpState.Lock() + s.udpState.failure = err.Error() + s.udpState.Unlock() + } + } + } + select { + case <-ctx.Done(): + return + case next, ok := <-updates: + if !ok { + return + } + current = next + } + } + }() + }) +} + +func (s *Service) publishUDPEvents(ctx context.Context, ep *udp.Endpoint) { + for _, stream := range []string{"activity", "state", "telemetry", "debug"} { + go func(stream string) { + after := s.Client.LatestEventID() + for { + event, err := s.Client.NextEventStream(ctx, after, "", stream) + if err != nil { + return + } + after = event.ID + if !ep.WantsEvents() { + continue + } + ep.Publish(map[string]any{"method": "controller.event", "params": event}) + if stream == "state" || stream == "telemetry" { + ep.Publish(map[string]any{"method": "controller.snapshot", "params": s.controllerSnapshot()}) + } + } + }(stream) + } +} + +func (s *Service) dispatchUDP(ctx context.Context, r Request, access Access) Response { + response := Response{JSONRPC: Version, ID: r.ID} + s.udpState.Lock() + ep := s.udpState.endpoint + failure := s.udpState.failure + s.udpState.Unlock() + fail := func(message string) Response { + response.Error = &RPCError{Code: -32602, Message: message} + return response + } + switch r.Method { + case "controller.udp.config.get": + response.Result = s.hostConfig().IPC.UDP + case "controller.udp.status": + if ep != nil { + response.Result = ep.Status() + } else { + response.Result = map[string]any{"running": false, "error": failure} + } + case "controller.udp.events.get": + if ep == nil { + return fail("UDP transport is not running") + } + response.Result = ep.Received() + case "controller.udp.config.set": + if access.Remote { + return fail("UDP provisioning requires local IPC") + } + if s.UpdateHostConfig == nil { + return fail("persistent host configuration unavailable") + } + var config udp.Config + if err := decodeParams(r.Params, &config); err != nil { + return fail(err.Error()) + } + if err := config.Validate(); err != nil { + return fail(err.Error()) + } + if err := s.UpdateHostConfig(func(c *appconfig.Config) error { c.IPC.UDP = config; return nil }); err != nil { + return fail(err.Error()) + } + response.Result = config + case "controller.udp.call", "controller.udp.subscribe", "controller.udp.unsubscribe": + if access.Remote { + return fail("UDP outbound routing requires local IPC; remote pivots are not allowed") + } + if ep == nil { + return fail("UDP transport is not running") + } + var params struct { + Peer string `json:"peer"` + Request Request `json:"request"` + TimeoutMS int `json:"timeout_ms"` + } + if err := decodeParams(r.Params, ¶ms); err != nil { + return fail(err.Error()) + } + if params.TimeoutMS == 0 { + params.TimeoutMS = 5000 + } + if params.TimeoutMS < 100 || params.TimeoutMS > 30000 { + return fail("timeout_ms must be 100..30000") + } + kind := udp.Request + var value any = params.Request + if r.Method == "controller.udp.subscribe" { + kind = udp.Subscribe + value = map[string]any{} + } + if r.Method == "controller.udp.unsubscribe" { + kind = udp.Unsubscribe + value = map[string]any{} + } + callContext, cancel := context.WithTimeout(ctx, time.Duration(params.TimeoutMS)*time.Millisecond) + defer cancel() + result, err := ep.Call(callContext, params.Peer, kind, value) + if err != nil { + response.Error = &RPCError{Code: -32000, Message: err.Error()} + } else { + response.Result = result + } + default: + response.Error = &RPCError{Code: -32601, Message: "unknown UDP method"} + } + return response +} diff --git a/Tools/Controller/rpc/client.go b/Tools/Controller/rpc/client.go index 8b148a63f..12157fdcc 100644 --- a/Tools/Controller/rpc/client.go +++ b/Tools/Controller/rpc/client.go @@ -1,12 +1,16 @@ package rpc import ( + "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" + udp "pccontroller.local/controller/transport/udp" + "sync" + "time" ) type Caller interface { @@ -73,6 +77,9 @@ func Dial(endpoint Endpoint, options ClientOptions) (*Client, error) { if err := endpoint.Validate(); err != nil { return nil, err } + if endpoint.Transport == TransportUDP { + return NewClient(&udpCaller{address: endpoint.Address}) + } return NewClient(&streamCaller{endpoint: endpoint, options: options}) } @@ -81,9 +88,76 @@ func Call(ctx context.Context, endpoint Endpoint, request Request, options Clien if err != nil { return Response{}, err } + defer client.Close() return client.Call(ctx, request) } +// Close releases a retained UDP client socket. Stream/in-process clients need +// no lifetime resource and remain unaffected. +func (client *Client) Close() error { + if client == nil { + return nil + } + if c, ok := client.caller.(interface{ Close() error }); ok { + return c.Close() + } + return nil +} + +type udpCaller struct { + sync.Mutex + address string + endpoint *udp.Endpoint + closed bool +} + +func (c *udpCaller) Close() error { + c.Lock() + defer c.Unlock() + c.closed = true + if c.endpoint != nil { + c.endpoint.Close() + } + return nil +} +func (c *udpCaller) Call(ctx context.Context, request Request) (Response, error) { + c.Lock() + if c.closed { + c.Unlock() + return Response{}, errors.New("UDP RPC client is closed") + } + if c.endpoint == nil { + var err error + c.endpoint, err = udp.StartClient(context.Background(), c.address) + if err != nil { + c.Unlock() + return Response{}, err + } + } + ep := c.endpoint + c.Unlock() + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + v, err := ep.Call(ctx, "remote", udp.Request, request) + if err != nil { + return Response{}, err + } + b, err := json.Marshal(v) + if err != nil { + return Response{}, err + } + var response Response + decoder := json.NewDecoder(bytes.NewReader(b)) + decoder.UseNumber() + if err = decoder.Decode(&response); err != nil { + return response, err + } + if response.Error != nil { + return response, response.Error + } + return response, nil +} + func (caller *streamCaller) Call(ctx context.Context, request Request) (Response, error) { connection, err := dialEndpoint(ctx, caller.endpoint, caller.options) if err != nil { diff --git a/Tools/Controller/rpc/types.go b/Tools/Controller/rpc/types.go index 8435091e0..4c50b3ad5 100644 --- a/Tools/Controller/rpc/types.go +++ b/Tools/Controller/rpc/types.go @@ -18,6 +18,7 @@ const ( TransportNamedPipe = "named_pipe" TransportUnix = "unix" TransportTCP = "tcp" + TransportUDP = "udp" ) type Request struct { @@ -67,7 +68,7 @@ func (endpoint Endpoint) Validate() error { return errors.New("RPC endpoint address is required") } switch transport { - case TransportNamedPipe, TransportUnix, TransportTCP: + case TransportNamedPipe, TransportUnix, TransportTCP, TransportUDP: return nil case TransportInProcess: return errors.New("in-process RPC does not have a dialable endpoint") diff --git a/Tools/Controller/transport/udp/codec.go b/Tools/Controller/transport/udp/codec.go new file mode 100644 index 000000000..6ce3baa76 --- /dev/null +++ b/Tools/Controller/transport/udp/codec.go @@ -0,0 +1,265 @@ +// Package udp implements the shared Digitalogic binary host datagram contract. +// It reuses firmware COBS and CRC-8/ATM, but never interprets host RPC as a +// raw board opcode. All hardware admission remains in the host dispatcher. +package udp + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strconv" + "unicode/utf8" + + "pccontroller.local/controller/internal/native" +) + +const MaxMessage = 256 * 1024 +const ChunkSize = 1024 +const DiscoveryAddress = "239.255.87.87:8790" +const ( + Discover byte = iota + 1 + Announce + Request + Response + Subscribe + Event + Unsubscribe +) + +type Packet struct { + Kind byte + Source [16]byte + ID uint64 + Index, Count uint16 + Data []byte +} + +func EncodePacket(p Packet) ([]byte, error) { + if p.Kind < Discover || p.Kind > Unsubscribe || p.ID == 0 || p.Count == 0 || p.Count > 256 || p.Index >= p.Count || len(p.Data) > ChunkSize { + return nil, errors.New("invalid UDP envelope") + } + b := append([]byte("DGLU"), p.Kind) + b = append(b, p.Source[:]...) + b = binary.LittleEndian.AppendUint64(b, p.ID) + b = binary.LittleEndian.AppendUint16(b, p.Index) + b = binary.LittleEndian.AppendUint16(b, p.Count) + b = append(b, p.Data...) + b = append(b, native.CRC8(b)) + return append(native.COBSEncode(b), 0), nil +} + +func DecodePacket(b []byte) (Packet, error) { + var p Packet + if len(b) < 2 || len(b) > 1100 || b[len(b)-1] != 0 { + return p, errors.New("invalid datagram size or delimiter") + } + raw, err := native.COBSDecode(b[:len(b)-1]) + if err != nil || len(raw) < 34 || string(raw[:4]) != "DGLU" || native.CRC8(raw[:len(raw)-1]) != raw[len(raw)-1] { + return p, errors.New("invalid datagram framing or CRC") + } + p.Kind = raw[4] + copy(p.Source[:], raw[5:21]) + p.ID = binary.LittleEndian.Uint64(raw[21:29]) + p.Index = binary.LittleEndian.Uint16(raw[29:31]) + p.Count = binary.LittleEndian.Uint16(raw[31:33]) + p.Data = append([]byte(nil), raw[33:len(raw)-1]...) + if p.Kind < Discover || p.Kind > Unsubscribe || p.ID == 0 || p.Count == 0 || p.Count > 256 || p.Index >= p.Count || len(p.Data) > ChunkSize || (p.Index+1 < p.Count && len(p.Data) != ChunkSize) { + return Packet{}, errors.New("invalid fragment") + } + return p, nil +} + +// Marshal transcodes the current application's typed JSON data model into +// binary values. No JSON text is carried over UDP. Integers remain exact. +func Marshal(value any) ([]byte, error) { + jsonBytes, err := json.Marshal(value) + if err != nil { + return nil, err + } + var v any + d := json.NewDecoder(bytes.NewReader(jsonBytes)) + d.UseNumber() + if err = d.Decode(&v); err != nil { + return nil, err + } + var b bytes.Buffer + err = encodeValue(&b, v, 0) + if b.Len() > MaxMessage { + return nil, errors.New("binary message exceeds 256 KiB") + } + return b.Bytes(), err +} + +func encodeValue(b *bytes.Buffer, v any, depth int) error { + if depth > 32 { + return errors.New("binary nesting exceeds 32") + } + length := func(n int) { _ = binary.Write(b, binary.LittleEndian, uint32(n)) } + switch v := v.(type) { + case nil: + b.WriteByte(0) + case bool: + if v { + b.WriteByte(2) + } else { + b.WriteByte(1) + } + case json.Number: + if i, e := strconv.ParseInt(string(v), 10, 64); e == nil { + b.WriteByte(3) + _ = binary.Write(b, binary.LittleEndian, i) + } else if u, e := strconv.ParseUint(string(v), 10, 64); e == nil { + b.WriteByte(4) + _ = binary.Write(b, binary.LittleEndian, u) + } else { + f, e := strconv.ParseFloat(string(v), 64) + if e != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return errors.New("invalid number") + } + b.WriteByte(5) + _ = binary.Write(b, binary.LittleEndian, f) + } + case string: + b.WriteByte(6) + length(len(v)) + b.WriteString(v) + case []any: + b.WriteByte(7) + length(len(v)) + for _, x := range v { + if err := encodeValue(b, x, depth+1); err != nil { + return err + } + } + case map[string]any: + b.WriteByte(8) + length(len(v)) + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if err := encodeValue(b, k, depth+1); err != nil { + return err + } + if err := encodeValue(b, v[k], depth+1); err != nil { + return err + } + } + default: + return fmt.Errorf("unsupported binary value %T", v) + } + if b.Len() > MaxMessage { + return errors.New("binary message exceeds 256 KiB") + } + return nil +} + +func Unmarshal(b []byte, out any) error { + if len(b) > MaxMessage { + return errors.New("oversize binary message") + } + r := bytes.NewReader(b) + v, err := decodeValue(r, 0) + if err != nil { + return err + } + if r.Len() != 0 { + return errors.New("trailing binary data") + } + j, err := json.Marshal(v) + if err != nil { + return err + } + d := json.NewDecoder(bytes.NewReader(j)) + d.UseNumber() + return d.Decode(out) +} + +func decodeValue(r *bytes.Reader, depth int) (any, error) { + if depth > 32 { + return nil, errors.New("binary nesting exceeds 32") + } + t, err := r.ReadByte() + if err != nil { + return nil, err + } + if t == 0 { + return nil, nil + } + if t == 1 || t == 2 { + return t == 2, nil + } + if t >= 3 && t <= 5 { + var n uint64 + if err = binary.Read(r, binary.LittleEndian, &n); err != nil { + return nil, err + } + switch t { + case 3: + return int64(n), nil + case 4: + return n, nil + default: + f := math.Float64frombits(n) + if math.IsNaN(f) || math.IsInf(f, 0) { + return nil, errors.New("invalid float") + } + return f, nil + } + } + var n uint32 + if err = binary.Read(r, binary.LittleEndian, &n); err != nil { + return nil, err + } + if uint64(n) > uint64(r.Len()) { + return nil, errors.New("invalid binary length") + } + switch t { + case 6: + b := make([]byte, n) + _, err = r.Read(b) + if !utf8.Valid(b) { + return nil, errors.New("invalid UTF-8") + } + return string(b), err + case 7: + v := make([]any, 0, n) + for i := uint32(0); i < n; i++ { + x, e := decodeValue(r, depth+1) + if e != nil { + return nil, e + } + v = append(v, x) + } + return v, nil + case 8: + v := make(map[string]any) + for i := uint32(0); i < n; i++ { + k, e := decodeValue(r, depth+1) + if e != nil { + return nil, e + } + s, ok := k.(string) + if !ok { + return nil, errors.New("object key is not text") + } + if _, ok = v[s]; ok { + return nil, errors.New("duplicate object key") + } + x, e := decodeValue(r, depth+1) + if e != nil { + return nil, e + } + v[s] = x + } + return v, nil + default: + return nil, errors.New("unknown binary tag") + } +} diff --git a/Tools/Controller/transport/udp/endpoint.go b/Tools/Controller/transport/udp/endpoint.go new file mode 100644 index 000000000..24b0feb9d --- /dev/null +++ b/Tools/Controller/transport/udp/endpoint.go @@ -0,0 +1,586 @@ +package udp + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "golang.org/x/net/ipv4" + "net" + "net/netip" + "strconv" + "sync" + "sync/atomic" + "time" +) + +type Config struct { + Enabled bool `json:"enabled"` + Listen string `json:"listen"` + Discovery bool `json:"discovery"` + BroadcastEvents bool `json:"broadcast_events"` + Peers map[string]string `json:"peers,omitempty"` +} + +func (c Config) Validate() error { + a, e := literalAddress(c.Listen) + if (c.Enabled || c.Listen != "") && (e != nil || a == nil || a.Port == 0 || a.IP == nil) { + return errors.New("UDP listen must be a literal IPv4 address and nonzero port") + } + if c.Enabled && c.Discovery && a != nil && a.IP.IsLoopback() { + return errors.New("UDP discovery requires a LAN or wildcard listening address") + } + if c.BroadcastEvents && !c.Discovery { + return errors.New("UDP event broadcast requires discovery") + } + if len(c.Peers) > 64 { + return errors.New("too many UDP peers") + } + for name, addr := range c.Peers { + a, e := literalAddress(addr) + if name == "" || len(name) > 128 || e != nil || a == nil || a.Port == 0 || a.IP == nil || a.IP.IsUnspecified() || a.IP.IsMulticast() || a.IP.Equal(net.IPv4bcast) { + return errors.New("UDP peers require names and literal unicast IPv4 endpoints") + } + } + return nil +} +func literalAddress(address string) (*net.UDPAddr, error) { + a, err := netip.ParseAddrPort(address) + if err != nil || !a.Addr().Is4() { + return nil, errors.New("literal IPv4 endpoint required") + } + return net.UDPAddrFromAddrPort(a), nil +} + +type Peer struct { + ID string `json:"id"` + Address string `json:"address"` + Identity any `json:"identity"` + SeenAt time.Time `json:"seen_at"` +} +type ReceivedEvent struct { + Source string `json:"source"` + Sequence uint64 `json:"sequence"` + Value any `json:"value"` + ReceivedAt time.Time `json:"received_at"` +} +type fragmentKey struct { + Address string + Source [16]byte + ID uint64 + Kind byte +} +type assembly struct { + Parts [][]byte + At time.Time +} +type receipt struct { + High uint64 + Bits uint64 +} +type subscription struct { + Address *net.UDPAddr + Until time.Time +} +type pendingCall struct { + Address string + Result chan any +} +type Endpoint struct { + conn *net.UDPConn + discovery *net.UDPConn + config Config + source [16]byte + sequence atomic.Uint64 + eventSequence atomic.Uint64 + ctx context.Context + cancel context.CancelFunc + handle func(context.Context, byte, any, *net.UDPAddr) any + identity any + mu sync.Mutex + eventMu sync.Mutex + peers map[string]Peer + events []ReceivedEvent + pending map[uint64]pendingCall + subs map[string]subscription + assemblies map[fragmentKey]*assembly + receipts map[string]*receipt + lastEvents map[string]uint64 + workers chan struct{} +} + +func Start(ctx context.Context, config Config, identity any, handle func(context.Context, byte, any, *net.UDPAddr) any) (*Endpoint, error) { + if err := config.Validate(); err != nil { + return nil, err + } + a, err := net.ResolveUDPAddr("udp4", config.Listen) + if err != nil { + return nil, err + } + c, err := net.ListenUDP("udp4", a) + if err != nil { + return nil, err + } + return startSocket(ctx, config, identity, handle, c) +} + +// StartClient owns a reusable ephemeral socket; it never advertises or accepts +// commands. Retain it for a session instead of opening a new sender per call. +func StartClient(ctx context.Context, address string) (*Endpoint, error) { + config := Config{Listen: "127.0.0.1:1", Peers: map[string]string{"remote": address}} + if err := config.Validate(); err != nil { + return nil, err + } + remote, _ := net.ResolveUDPAddr("udp4", address) + ip := net.IPv4zero + if remote.IP.IsLoopback() { + ip = net.IPv4(127, 0, 0, 1) + } + c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip}) + if err != nil { + return nil, err + } + return startSocket(ctx, config, map[string]any{"app": "controller-client"}, func(context.Context, byte, any, *net.UDPAddr) any { + return map[string]any{"error": "client endpoint does not accept commands"} + }, c) +} + +func startSocket(parent context.Context, config Config, identity any, handle func(context.Context, byte, any, *net.UDPAddr) any, c *net.UDPConn) (*Endpoint, error) { + var err error + ctx := parent + ctx, cancel := context.WithCancel(ctx) + e := &Endpoint{conn: c, config: config, ctx: ctx, cancel: cancel, identity: identity, handle: handle, peers: map[string]Peer{}, pending: map[uint64]pendingCall{}, subs: map[string]subscription{}, assemblies: map[fragmentKey]*assembly{}, receipts: map[string]*receipt{}, lastEvents: map[string]uint64{}, workers: make(chan struct{}, 16)} + if _, err = rand.Read(e.source[:]); err != nil { + c.Close() + cancel() + return nil, err + } + if config.Discovery { + group, _ := net.ResolveUDPAddr("udp4", DiscoveryAddress) + iface := interfaceForIP(c.LocalAddr().(*net.UDPAddr).IP) + if iface != nil { + if err = ipv4.NewPacketConn(c).SetMulticastInterface(iface); err != nil { + c.Close() + cancel() + return nil, err + } + } + e.discovery, err = net.ListenMulticastUDP("udp4", iface, group) + if err != nil { + c.Close() + cancel() + return nil, fmt.Errorf("UDP discovery: %w", err) + } + go e.read(e.discovery) + } + go e.read(c) + go func() { + <-ctx.Done() + c.Close() + if e.discovery != nil { + e.discovery.Close() + } + }() + go e.beacons() + return e, nil +} +func interfaceForIP(ip net.IP) *net.Interface { + if ip.IsUnspecified() { + return nil + } + interfaces, _ := net.Interfaces() + for _, iface := range interfaces { + addresses, _ := iface.Addrs() + for _, address := range addresses { + network, _, err := net.ParseCIDR(address.String()) + if err == nil && network.Equal(ip) { + copy := iface + return © + } + } + } + return nil +} +func (e *Endpoint) Close() { + e.cancel() + _ = e.conn.Close() + if e.discovery != nil { + _ = e.discovery.Close() + } +} +func (e *Endpoint) Address() string { return e.conn.LocalAddr().String() } +func (e *Endpoint) WantsEvents() bool { + if e.config.BroadcastEvents && e.config.Discovery { + return true + } + e.mu.Lock() + defer e.mu.Unlock() + for _, sub := range e.subs { + if time.Now().Before(sub.Until) { + return true + } + } + return false +} +func (e *Endpoint) Status() any { + e.mu.Lock() + defer e.mu.Unlock() + peers := []Peer{} + for _, p := range e.peers { + if time.Since(p.SeenAt) < 45*time.Second { + peers = append(peers, p) + } + } + return map[string]any{"running": e.ctx.Err() == nil, "listen": e.Address(), "id": hex.EncodeToString(e.source[:]), "peers": peers, "received_events": len(e.events)} +} + +// Received returns a bounded page. UDP consumers explicitly refresh the +// authoritative peer after gaps; these observations are never board commands. +func (e *Endpoint) Received() any { + e.mu.Lock() + defer e.mu.Unlock() + result := []ReceivedEvent{} + for i := len(e.events) - 1; i >= 0; i-- { + next := append([]ReceivedEvent{e.events[i]}, result...) + b, err := Marshal(next) + if err != nil || len(b) > 128*1024 { + break + } + result = next + } + return result +} +func (e *Endpoint) send(kind byte, id uint64, value any, to *net.UDPAddr) error { + b, err := Marshal(value) + if err != nil { + return err + } + count := (len(b) + ChunkSize - 1) / ChunkSize + if count == 0 { + count = 1 + } + for i := 0; i < count; i++ { + end := min((i+1)*ChunkSize, len(b)) + wire, er := EncodePacket(Packet{Kind: kind, Source: e.source, ID: id, Index: uint16(i), Count: uint16(count), Data: b[i*ChunkSize : end]}) + if er != nil { + return er + } + if _, er = e.conn.WriteToUDP(wire, to); er != nil { + return er + } + } + return nil +} +func (e *Endpoint) Call(ctx context.Context, target string, kind byte, value any) (any, error) { + addr, ok := e.config.Peers[target] + if !ok { + return nil, errors.New("UDP target is not provisioned") + } + a, err := net.ResolveUDPAddr("udp4", addr) + if err != nil { + return nil, err + } + if kind != Discover { + announcement, err := e.Call(ctx, target, Discover, map[string]any{}) + if err != nil { + return nil, err + } + metadata, ok := announcement.(map[string]any) + if !ok { + return nil, errors.New("invalid UDP identity") + } + value = map[string]any{"target_id": metadata["id"], "body": value} + } + id := e.sequence.Add(1) + result := make(chan any, 1) + e.mu.Lock() + if len(e.pending) >= 64 { + e.mu.Unlock() + return nil, errors.New("UDP pending calls full") + } + e.pending[id] = pendingCall{a.String(), result} + e.mu.Unlock() + defer func() { e.mu.Lock(); delete(e.pending, id); e.mu.Unlock() }() + if err = e.send(kind, id, value, a); err != nil { + return nil, err + } + select { + case v := <-result: + return v, nil + case <-ctx.Done(): + return nil, errors.New("UDP outcome unknown: request is not retried") + case <-e.ctx.Done(): + return nil, errors.New("UDP stopped; outcome unknown") + } +} +func (e *Endpoint) Publish(value any) { + e.eventMu.Lock() + defer e.eventMu.Unlock() + value = map[string]any{"sequence": e.eventSequence.Add(1), "event": value} + e.mu.Lock() + targets := []*net.UDPAddr{} + for k, s := range e.subs { + if time.Now().Before(s.Until) { + targets = append(targets, s.Address) + } else { + delete(e.subs, k) + } + } + e.mu.Unlock() + id := e.sequence.Add(1) + for _, a := range targets { + _ = e.send(Event, id, value, a) + } + if e.config.BroadcastEvents && e.config.Discovery { + a, _ := net.ResolveUDPAddr("udp4", DiscoveryAddress) + _ = e.send(Event, id, value, a) + } +} +func (e *Endpoint) announcement() any { + return map[string]any{"id": hex.EncodeToString(e.source[:]), "identity": e.identity, "port": e.conn.LocalAddr().(*net.UDPAddr).Port, "transport": "digitalogic-binary-udp", "commands": "unicast", "max_message": MaxMessage} +} +func (e *Endpoint) beacons() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + announce := func() { + if e.config.Discovery { + a, _ := net.ResolveUDPAddr("udp4", DiscoveryAddress) + _ = e.send(Announce, e.sequence.Add(1), e.announcement(), a) + a.IP = net.IPv4bcast + _ = e.send(Announce, e.sequence.Add(1), e.announcement(), a) + } + } + announce() + for { + select { + case <-e.ctx.Done(): + return + case <-t.C: + announce() + e.mu.Lock() + for k, a := range e.assemblies { + if time.Since(a.At) > 3*time.Second { + delete(e.assemblies, k) + } + } + for k, p := range e.peers { + if time.Since(p.SeenAt) > 45*time.Second { + delete(e.peers, k) + } + } + e.mu.Unlock() + } + } +} +func (e *Endpoint) read(c *net.UDPConn) { + buffer := make([]byte, 1101) + for { + n, from, err := c.ReadFromUDP(buffer) + if err != nil { + return + } + p, err := DecodePacket(buffer[:n]) + if err != nil || p.Source == e.source { + continue + } + // Discovery socket must never accept actuating request/subscription traffic. + if c == e.discovery && p.Kind != Discover && p.Kind != Announce && p.Kind != Event { + continue + } + value, ok := e.reassemble(p, from) + if !ok { + continue + } + switch p.Kind { + case Discover: + _ = e.send(Announce, p.ID, e.announcement(), from) + case Announce: + v, ok := value.(map[string]any) + if !ok { + continue + } + number, ok := v["port"].(json.Number) + port, err := strconv.Atoi(string(number)) + if !ok || err != nil || port < 1 || port > 65535 { + continue + } + id := hex.EncodeToString(p.Source[:]) + if v["id"] != id { + continue + } + e.mu.Lock() + if call, ok := e.pending[p.ID]; ok && call.Address == from.String() { + select { + case call.Result <- value: + default: + } + } + if len(e.peers) < 64 || e.peers[id].ID != "" { + e.peers[id] = Peer{id, net.JoinHostPort(from.IP.String(), fmt.Sprint(int(port))), v["identity"], time.Now()} + } + e.mu.Unlock() + case Response: + e.mu.Lock() + call, ok := e.pending[p.ID] + e.mu.Unlock() + if ok && call.Address == from.String() { + select { + case call.Result <- value: + default: + } + } + case Event: + envelope, ok := value.(map[string]any) + if !ok { + continue + } + number, ok := envelope["sequence"].(json.Number) + if !ok { + continue + } + seq, err := strconv.ParseUint(string(number), 10, 64) + if err != nil || seq == 0 { + continue + } + source := hex.EncodeToString(p.Source[:]) + e.mu.Lock() + previous := e.lastEvents[source] + if seq <= previous || (previous == 0 && len(e.lastEvents) >= 256) { + e.mu.Unlock() + continue + } + e.lastEvents[source] = seq + if len(e.events) >= 64 { + e.events = e.events[1:] + } + e.events = append(e.events, ReceivedEvent{source, seq, map[string]any{"event": envelope["event"], "refresh_required": previous == 0 || seq != previous+1}, time.Now()}) + e.mu.Unlock() + case Request, Subscribe, Unsubscribe: + wrapped, ok := value.(map[string]any) + if !ok || wrapped["target_id"] != hex.EncodeToString(e.source[:]) { + continue + } + value = wrapped["body"] + if !e.claim(p, from) { + continue + } + select { + case e.workers <- struct{}{}: + default: + _ = e.send(Response, p.ID, map[string]any{"error": map[string]any{"code": -32000, "message": "UDP dispatcher busy; request not executed"}}, from) + continue + } + go func(p Packet, value any, from *net.UDPAddr) { + defer func() { <-e.workers }() + ctx, cancel := context.WithTimeout(e.ctx, 30*time.Second) + defer cancel() + result := e.handle(ctx, p.Kind, value, from) + if p.Kind == Subscribe { + if m, ok := result.(map[string]any); ok && m["subscribed"] == true { + e.mu.Lock() + if len(e.subs) < 64 || e.subs[from.String()].Address != nil { + e.subs[from.String()] = subscription{from, time.Now().Add(30 * time.Second)} + } else { + result = map[string]any{"error": map[string]any{"code": -32000, "message": "UDP subscriptions full"}} + } + e.mu.Unlock() + } + } + if p.Kind == Unsubscribe { + e.mu.Lock() + delete(e.subs, from.String()) + e.mu.Unlock() + } + if err := e.send(Response, p.ID, result, from); err != nil { + _ = e.send(Response, p.ID, map[string]any{"error": map[string]any{"code": -32010, "message": err.Error()}}, from) + } + }(p, value, from) + } + } +} +func (e *Endpoint) claim(p Packet, from *net.UDPAddr) bool { + e.mu.Lock() + defer e.mu.Unlock() + key := from.String() + hex.EncodeToString(p.Source[:]) + r := e.receipts[key] + if r == nil { + if len(e.receipts) >= 256 { + return false + } + r = &receipt{} + e.receipts[key] = r + } + if p.ID > r.High { + delta := p.ID - r.High + if delta >= 64 { + r.Bits = 0 + } else { + r.Bits <<= delta + } + r.High = p.ID + r.Bits |= 1 + return true + } + delta := r.High - p.ID + if delta >= 64 || r.Bits&(uint64(1)< 3*time.Second { + delete(e.assemblies, key) + a = nil + } + if a == nil { + for k, old := range e.assemblies { + if time.Since(old.At) > 3*time.Second { + delete(e.assemblies, k) + } + } + if len(e.assemblies) >= 16 { + return nil, false + } + a = &assembly{make([][]byte, p.Count), time.Now()} + e.assemblies[key] = a + } + if len(a.Parts) != int(p.Count) { + delete(e.assemblies, key) + return nil, false + } + if a.Parts[p.Index] != nil { + if string(a.Parts[p.Index]) != string(p.Data) { + delete(e.assemblies, key) + } + return nil, false + } + a.Parts[p.Index] = p.Data + var size int + for _, b := range a.Parts { + if b == nil { + return nil, false + } + size += len(b) + } + if size > MaxMessage { + delete(e.assemblies, key) + return nil, false + } + b := make([]byte, 0, size) + for _, part := range a.Parts { + b = append(b, part...) + } + delete(e.assemblies, key) + var value any + if Unmarshal(b, &value) != nil { + return nil, false + } + return value, true +} diff --git a/Tools/Controller/web/src/advanced-workbench.tsx b/Tools/Controller/web/src/advanced-workbench.tsx index c36d71125..e66bb10c7 100644 --- a/Tools/Controller/web/src/advanced-workbench.tsx +++ b/Tools/Controller/web/src/advanced-workbench.tsx @@ -69,6 +69,7 @@ import { import { rpc } from './api' import { redactSensitiveCommand, shellArgument as quoteArgument } from './command-line' import { SevenSegmentPreview } from './seven-segment-preview' +import { UDPSettings } from './udp-settings' import type { FrontPanelState } from './types' import type { SharedViewProps } from './views' import { peripheralAvailability } from './peripheral-availability' @@ -929,6 +930,7 @@ export function AdvancedWorkbench({ +
setDiscoveryInstanceName(event.target.value)} /> setDiscoveryBroadcastPort(boundedInteger(event.target.value, 37889, 1024, 65535))} /> diff --git a/Tools/Controller/web/src/udp-settings.tsx b/Tools/Controller/web/src/udp-settings.tsx new file mode 100644 index 000000000..e7e02ad8e --- /dev/null +++ b/Tools/Controller/web/src/udp-settings.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import { RefreshCw, Save, Trash2 } from 'lucide-react' +import { rpc } from './api' +import { Button, TextField, Toggle } from './components' + +type Config = { enabled: boolean; listen: string; discovery: boolean; broadcast_events: boolean; peers?: Record } +type Status = { running: boolean; error?: string; peers?: { id: string; address: string; identity: { app?: string } }[] } + +export function UDPSettings({ locale }: { locale: string }) { + const text = (en: string, fa: string) => locale === 'fa' ? fa : en + const [config,setConfig] = useState() + const [status,setStatus] = useState() + const [error,setError] = useState('') + const [busy,setBusy] = useState(false) + const [name,setName] = useState('') + const [address,setAddress] = useState('') + const load = async () => { + try { const [c,s] = await Promise.all([rpc('controller.udp.config.get'),rpc('controller.udp.status')]); setConfig({...c,listen:c.listen || '127.0.0.1:8787'}); setStatus(s); setError('') } + catch(e) { setError(String(e)) } + } + useEffect(() => { void load() }, []) + if (!config) return error ?

{error}

: null + const update = (values: Partial) => setConfig({...config,...values}) + const save = async () => { + setBusy(true) + try { await rpc('controller.udp.config.set',config); setError(''); setStatus(undefined) } + catch(e) { setError(String(e)) } + finally { setBusy(false) } + } + return
+

{text('Binary UDP','UDP باینری')}

+ update({enabled})} label={text('Enable transport','فعال‌سازی ارتباط')} /> + update({listen:e.target.value})} /> + update({discovery,broadcast_events:discovery?config.broadcast_events:false})} label={text('Discover and announce peers','کشف و معرفی همتاها')} /> + {config.discovery && update({broadcast_events})} label={text('Broadcast events','پخش رویدادها')} />} + {Object.entries(config.peers ?? {}).map(([key,value])=>
+ {key} · {value} +
)} +
+ setName(e.target.value)} /> + setAddress(e.target.value)} /> +
+ +
+ {(error||status?.error) &&

{error||status?.error}

} + {status && {status.running ? text('Listening','در حال گوش دادن') : text('Stopped','متوقف')}} + {status?.peers?.map((peer)=>
{peer.identity?.app} · {peer.address} + +
)} +
+} diff --git a/docs/BINARY-UDP.md b/docs/BINARY-UDP.md new file mode 100644 index 000000000..a3581a5d4 --- /dev/null +++ b/docs/BINARY-UDP.md @@ -0,0 +1,153 @@ +# Binary UDP between PCController and Pealayer + +This is a host transport, not an alternative firmware implementation or a raw +UART bypass. Both apps dispatch the same typed RPC methods, errors, authority +checks and board admission rules used by their existing APIs. Firmware remains +unchanged. Discovery does not provision a command target or grant authority. + +## Provisioning + +UDP is disabled by default. PCController stores the following in `ipc.udp`; +Pealayer stores the same object in `binary_udp`: + +```json +{ + "enabled": true, + "listen": "127.0.0.1:8787", + "discovery": false, + "broadcast_events": false, + "peers": { "player": "127.0.0.1:8080" } +} +``` + +Use the destination's actual address and port. UDP and TCP can use the same +numeric port without conflict. Listening and named peers currently use literal +IPv4 addresses. LAN discovery requires a LAN or wildcard bind; PCController +also requires its existing `ipc.allow_remote`. Never enable state broadcast on +an untrusted network: CRC detects corruption, not malicious senders. The current +alpha authentication contract is unchanged. No token is advertised and discovery +is not authenticated. Remote-access/firewall/tunnel services are not modified. + +PCController's Advanced network panel manages transport settings and named peers. +Pealayer's shared native/Web Preferences includes the Binary UDP group. Named +peers can be supplied through the complete typed UDP configuration API. Changing +UDP settings closes the previous endpoint and starts a new process identity; +clients reconnect and refresh rather than following stale commands. + +Both dispatchers expose these method suffixes: + +| PCController prefix `controller.udp.` / Pealayer prefix `pealayer.udp.` | Meaning | +| --- | --- | +| `config.get`, `config.set` | Read/provision the transport configuration | +| `status` | Running state, bind error, process identity, discovered peers, event count | +| `call` | `{peer, request: {jsonrpc:"2.0", id, method, params}, timeout_ms}` | +| `subscribe`, `unsubscribe` | `{peer, timeout_ms}`; receive remote state/event pushes | +| `events.get` | Bounded received observations, sequence and refresh-required state | + +Provisioning and outbound routing require local IPC/loopback; they are not +remote bridge pivots. PCController exposes these through its normal IPC/HTTP/WS +dispatcher. Pealayer exposes them through native IPC, `/api/rpc`, `/ws`, and the +process-local `/api/udp` endpoint, even in consumer mode. Its configuration write +returns `accepted:true` when queued; inspect config/status to confirm application. + +For example, a local PCController RPC call to the configured player is: + +```json +{"jsonrpc":"2.0","id":1,"method":"controller.udp.call","params":{"peer":"player","request":{"jsonrpc":"2.0","id":2,"method":"pealayer.status","params":{}}}} +``` + +Commands are asynchronous correlated RPCs delivered on packet receipt, not a +polling command loop. Existing board/menu/firmware/automation APIs retain their +semantics. Use their existing bounded chunk APIs for large uploads, not one +oversize UDP message. Subscription leases last 30 seconds; renew before expiry +(the native Pealayer bridge renews every 10 seconds). No event is automatically +converted to a hardware command or rebroadcast, preventing peer echo loops. + +Pealayer's hardware endpoint accepts `udp://IP:port`. It uses binary RPC and +pushed controller events/snapshots, including the existing acknowledged front +panel action path. The Go RPC client also accepts `rpc.TransportUDP`; retain a +dialled client per session and call `Close` afterward. + +## Wire contract + +One datagram is `COBS(raw) + 00`, reusing the production firmware's COBS and +CRC-8/ATM implementation (polynomial 07, initial 00). The host envelope does not +use firmware opcodes or the firmware's 48-byte payload limit. + +| Raw offset | Bytes | Meaning | +| --- | --- | --- | +| 0 | 4 | ASCII `DGLU` | +| 4 | 1 | Kind: discover 1, announce 2, request 3, response 4, subscribe 5, event 6, unsubscribe 7 | +| 5 | 16 | Random sender process/session UUID bytes | +| 21 | 8 | Little-endian nonzero message/correlation ID | +| 29 | 2 | Little-endian zero-based fragment index | +| 31 | 2 | Little-endian fragment count, 1..256 | +| 33 | 0..1024 | Binary-value chunk; all non-final chunks are exactly 1024 bytes | +| last | 1 | CRC of every preceding raw byte | + +Maximum assembled message is 256 KiB, nesting 32. Datagram size stays below +1100 bytes, avoiding ordinary Ethernet IP fragmentation. Reassembly is keyed by +source address, UUID, ID and kind, bounded to 16 messages, and expires after +three seconds. Inconsistent fragments, bad CRC/length/UTF-8, duplicate object +keys, trailing bytes and nonfinite floats are rejected. + +Binary value tags are 0 null, 1 false, 2 true, 3 signed i64 LE, 4 unsigned u64 +LE, 5 IEEE-754 f64 LE, 6 UTF-8 string (u32 LE byte length), 7 array (u32 LE +element count), 8 object (u32 LE entry count, then string key/value pairs). +Object keys are encoded in sorted order. Integers are never routed through a +floating-point intermediary. JSON is used only inside the application adapter, +not on the UDP wire. + +Discover sends an empty object; announce carries `id` (sender UUID as 32 hex +characters), `identity`, `port`, `transport`, `commands` and `max_message`. +Announcements use `239.255.87.87:8790` and IPv4 broadcast every 15 seconds; +discover also receives a unicast announce with the request correlation ID. +Both applications can share the discovery listener. Peers expire after 45 +seconds. The source IP plus announced port determines the observed endpoint; +advertised arbitrary URLs are never followed. + +Before a call, resolve the configured endpoint's current identity. Request, +subscribe and unsubscribe payloads MUST be `{target_id: , body: ...}`. +An old identity or a missing target cannot actuate a host. Only discovery and +events are admitted on the shared discovery socket. Responses preserve the +request ID. Events are `{sequence: , event: }`. Consumers suppress old/duplicate events and refresh the +authoritative source after gaps/queue overflow/restart; observations alone do +not replace board state. Discovery metadata, commands and events have separate +semantics. + +Command receipts use a 64-ID replay window per sender session. Replayed or +too-old commands are not executed. The 256-sender ledger is never evicted to +make an old command eligible again; exhaustion fails closed until the operator +restarts/reconfigures the endpoint. Workers, subscriptions, pending calls and +received-event history are bounded (16/64/64/64 respectively). A duplicate +command does not receive a second execution or a fabricated acknowledgement. +There are no automatic mutation retransmissions: after a timeout the outcome is +unknown. Query again or reconcile the authoritative state before deciding to +issue a new command. UDP is not guaranteed delivery and cannot guarantee an +actuation latency; existing media timing guards are unchanged. + +## Evidence and remaining acceptance + +The initial implementation passes Go compilation/static vet, PCController Web +typechecking and Pealayer offline `cargo check`. A pure Rust transport probe +used the actual isolated Go Host dispatcher on loopback for discovery/identity, +ping, snapshot, a 13,536-byte fragmented binary UI catalog, and a pushed +`udp_probe` event. No board, player, production settings or LAN discovery was +used. Full suites and CI waits were skipped at the user's request. +The Go/Rust golden fixture is byte-identical (109 bytes), including i64 minimum, +u64 maximum, f64, UTF-8, null, booleans and a collection. Run each probe with +`--codec` to inspect it without opening any socket. + +Developer probes are `Tools/Controller/examples/udp-peer` and Pealayer's +`tools/udp-probe`; both use production codec/client code. They are not alternate +implementations or production services. The Go fixture disables native IPC, +autoconnection and integrations, binds only 127.0.0.1:18877/18878, uses temporary +private configuration and exits after 60 seconds. Rust only contacts that fixture. + +Installed Cafe/Erfan binaries do not yet contain this new implementation. +Packaging, approved host updater activation, live mutual LAN discovery, +loss/reordering stress, sustained media/board timing and human/physical +acceptance remain separate gates. Erfan's previous Pealayer updater policy +blocker still applies; do not bypass it. No firmware flash, movement, relay or +tone is needed to validate UDP framing/discovery. From f39cde16090bd1f1f8a861f3b215d0eacfed6c6b Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 02:29:48 +0330 Subject: [PATCH 13/72] fix: guard UDP peer routing against recursive pivots [skip ci] --- Tools/Controller/internal/ipcjson/ipc.go | 11 ++++++++++- Tools/Controller/internal/ipcjson/udp.go | 9 +++++++-- docs/BINARY-UDP.md | 5 +++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Tools/Controller/internal/ipcjson/ipc.go b/Tools/Controller/internal/ipcjson/ipc.go index 1f3ecff9e..b07ee7ba9 100644 --- a/Tools/Controller/internal/ipcjson/ipc.go +++ b/Tools/Controller/internal/ipcjson/ipc.go @@ -2254,6 +2254,11 @@ func (service *Service) authorizeAccess( method string, params json.RawMessage, ) error { + if strings.EqualFold(strings.TrimSpace(access.Transport), "udp") { + if err := ValidateBridgeRequest(Request{Method: method, Params: params}); err != nil { + return err + } + } if !access.Remote { return nil } @@ -2277,6 +2282,10 @@ func (service *Service) authorizeAccess( func bridgeIngressCanPivot(method string, params json.RawMessage) bool { switch strings.ToLower(strings.TrimSpace(method)) { + case "controller.udp.call", "controller.udp.subscribe", "controller.udp.unsubscribe", "controller.udp.config.set", + "pealayer.udp.call", "pealayer.udp.subscribe", "pealayer.udp.unsubscribe", "pealayer.udp.config.set", + "pealayer.process.connect", "config.update", "pealayer.config.update", "config.reload", "pealayer.config.reload": + return true case "controller.bridge.call", "controller.peer.update.host", "controller.discovery.connect": return true case "controller.network.peers.set", "controller.integrations.local.set", "controller.hotkeys.set", @@ -2309,7 +2318,7 @@ func bridgeCommandCanPivot(command string) bool { root := strings.FieldsFunc(words[2], func(character rune) bool { return character == '.' || character == '[' }) if len(root) != 0 { switch root[0] { - case "integrations", "host_menu", "host_menus", "hotkeys", "automations": + case "ipc", "integrations", "host_menu", "host_menus", "hotkeys", "automations": return true } } diff --git a/Tools/Controller/internal/ipcjson/udp.go b/Tools/Controller/internal/ipcjson/udp.go index 0d09221d7..656043245 100644 --- a/Tools/Controller/internal/ipcjson/udp.go +++ b/Tools/Controller/internal/ipcjson/udp.go @@ -172,8 +172,8 @@ func (s *Service) dispatchUDP(ctx context.Context, r Request, access Access) Res } response.Result = config case "controller.udp.call", "controller.udp.subscribe", "controller.udp.unsubscribe": - if access.Remote { - return fail("UDP outbound routing requires local IPC; remote pivots are not allowed") + if access.Transport == "udp" || access.Transport == "bridge" { + return fail("UDP/bridge ingress may not pivot to another peer") } if ep == nil { return fail("UDP transport is not running") @@ -186,6 +186,11 @@ func (s *Service) dispatchUDP(ctx context.Context, r Request, access Access) Res if err := decodeParams(r.Params, ¶ms); err != nil { return fail(err.Error()) } + if r.Method == "controller.udp.call" { + if err := ValidateBridgeRequest(params.Request); err != nil { + return fail(err.Error()) + } + } if params.TimeoutMS == 0 { params.TimeoutMS = 5000 } diff --git a/docs/BINARY-UDP.md b/docs/BINARY-UDP.md index a3581a5d4..1570decb6 100644 --- a/docs/BINARY-UDP.md +++ b/docs/BINARY-UDP.md @@ -44,8 +44,9 @@ Both dispatchers expose these method suffixes: | `subscribe`, `unsubscribe` | `{peer, timeout_ms}`; receive remote state/event pushes | | `events.get` | Bounded received observations, sequence and refresh-required state | -Provisioning and outbound routing require local IPC/loopback; they are not -remote bridge pivots. PCController exposes these through its normal IPC/HTTP/WS +Provisioning requires local IPC/loopback. Existing API clients may route to a +named configured peer under the current access/control policy. UDP/bridge ingress +cannot chain another hop or persist a route change. PCController exposes these through its normal IPC/HTTP/WS dispatcher. Pealayer exposes them through native IPC, `/api/rpc`, `/ws`, and the process-local `/api/udp` endpoint, even in consumer mode. Its configuration write returns `accepted:true` when queued; inspect config/status to confirm application. From 154380252c27f00e7ccf11207266d0ec08534274 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 02:33:58 +0330 Subject: [PATCH 14/72] feat(mobile): add interactive sequence and native cue timing editors --- mobile/ACCEPTANCE.md | 35 +- mobile/AGENTS.md | 10 + mobile/README.md | 6 +- mobile/STANDALONE.md | 48 +++ mobile/lib/main.dart | 66 +++- mobile/lib/unified/cue_editor.dart | 201 ++++++++++ mobile/lib/unified/effect_editor.dart | 139 +++++-- mobile/lib/unified/sequence_timeline.dart | 429 ++++++++++++++++++++++ mobile/pubspec.yaml | 2 +- mobile/test/cue_editor_test.dart | 140 +++++++ mobile/test/sequence_timeline_test.dart | 151 ++++++++ mobile/test/unified_acceptance_test.dart | 50 +++ 12 files changed, 1236 insertions(+), 41 deletions(-) create mode 100644 mobile/AGENTS.md create mode 100644 mobile/STANDALONE.md create mode 100644 mobile/lib/unified/cue_editor.dart create mode 100644 mobile/lib/unified/sequence_timeline.dart create mode 100644 mobile/test/cue_editor_test.dart create mode 100644 mobile/test/sequence_timeline_test.dart diff --git a/mobile/ACCEPTANCE.md b/mobile/ACCEPTANCE.md index cf6e822ac..8f25acd2a 100644 --- a/mobile/ACCEPTANCE.md +++ b/mobile/ACCEPTANCE.md @@ -87,6 +87,37 @@ Pealayer's current paired-peer-deployment receipt; live Android routing must still be verified against a reachable authoritative host. Full native/Web designer/settings parity and direct standalone hardware control remain open. +## Native authoring pass, preview 0.1.3+4 + +- Replaced static sequence bars with a native interactive timeline: shared step + selection, release-only start/duration edits, 10 ms snapping, zoom/Fit, + horizontal scrolling and dimmed repetition previews. Moving preserves the + existing duration and unknown descriptor fields. Changed or removed step + ownership cancels a gesture instead of retargeting it. +- Added RGB/brightness sliders and color swatches to sequence steps. Inspector + timing fields refresh after canvas edits without recreating input on every + typed character. +- Pealayer cues now open a native timing editor. Unchanged Save sends nothing; + moving uses `timeline.cue.place`, explicit permitted resizing uses + `effect_cue.update`. Exact millisecond parsing, the current 1 ms..24 hour + resize contract, intrinsic durations, peer/cue identity, unsaved edits and + uncertain/rejected requests retain their appropriate guards. +- Cafe's live COM3 controller and paused Pealayer were verified read-only over + the existing SSH path. Direct Android HTTP reachability to both published + endpoints timed out on the phone and tablet. These are separate observations. +- The existing Go C API successfully cross-compiled with the installed Android + NDK as an AArch64 ELF shared object. This is a concrete shared-core path for + standalone work, but it is not yet packaged, started or connected to Android + USB, and does not satisfy standalone hardware acceptance. +- The prior Android CI passed. The broader host Linux-x64 job failed at the + status-LED observation in the two-board mirroring test; that failure is not + described as all-green CI and is independent of the Flutter source checks. + +The existing user checklist remains open. Complete native cue properties, +direct USB/programmer ownership, Android foreground service/receiver lifecycle, +native UDP discovery/command/event integration and physical acceptance are +continuation work, not claims from this preview. + ## Evidence to record at handoff - Locked offline Flutter restore, analyzer, full inherited + new tests. @@ -141,8 +172,8 @@ designer/settings parity and direct standalone hardware control remain open. production signing/update channel remain investigation/continuation items. - The retained Pakar implementations are donor/reference coverage, not proof that their ESP-specific activities control the PCController board. -- Pealayer cue editing currently opens the existing host editor. This does not - claim that its entire native designer has been ported into Flutter. +- Pealayer cue timing edits are native on Android. Its complete cue properties, + channel modes, track routing and effect designer are not yet fully ported. Keep physical, renderer, source/CI and deployment claims separate. diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md new file mode 100644 index 000000000..b98a817ca --- /dev/null +++ b/mobile/AGENTS.md @@ -0,0 +1,10 @@ +# Flutter controller implementation rules + +Read the parent AGENTS.md and ../docs/CONTEXTUAL-UI.md in full before changing this app. The current basic design is explicitly rejected, and all unified-controller acceptance rules in the parent apply to this directory. + +- One integrated, activity-oriented PCController/Pealayer workspace; no separate product tabs. Offer compact expert controls, responsive phone/tablet composition, meaningful headers/cards/dialogs, coherent semantic colors, icons, charts and pills. +- A peer has one connection action reflecting its actual lifecycle. Omit unavailable-only cards, irrelevant actions, dormant access-token fields and filler narration. Unknown is not zero. Keep useful warnings, safety restrictions and failed/uncertain operation receipts. +- Appearance/branding preferences must update the actual Android activity/task presentation and selectable launcher aliases. Preserve one or both launcher entries for the same unified app and include PakarController palettes without replacing existing schemes. +- Discovery is DNS-SD/mDNS, SSDP and MNDP with verified product/endpoint metadata. Automatic connection respects saved intent and the live selected peer; it must not seize board authority, actuate hardware or replay an uncertain command. +- Peer APK transfers/upgrades share IPC/RPC and must be bounded, integrity-checked, package/signature-checked and mediated by Android installation consent. Do not remove existing protected local automation security to hide an irrelevant remote-peer token UI. +- Preserve dirty work and active owner file scopes. Do not claim design or physical acceptance from compilation, fixture rendering or APK delivery. Keep screenshots honest and separate controlled fixtures from actual device/network state. diff --git a/mobile/README.md b/mobile/README.md index 8720188e4..a0ac76701 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -15,9 +15,11 @@ PakarController remains a separate project and Android installation. durable operation status and WebSocket-triggered progress refresh. Toolchain provisioning is indeterminate, never a fabricated 0% progress bar. - Pealayer: playback/stop/seek/volume/mute, media opening, workspaces, - effect playback/cue placement and opening the existing host cue editor. + effect playback/cue placement and a native cue timing editor. Intrinsic + duration remains read-only; moving and resizing use the current host APIs. - Workspace: local drafts and a native sequence designer with selectable step - rows, timing/duration/repetition fields and dimmed repetition previews. Existing + rows, timeline dragging/resizing, zoom, snapping, RGB controls, + timing/duration/repetition fields and dimmed repetition previews. Existing descriptors retain their unknown fields; technical editing is collapsed. Publishing to PCController is explicit and does not run the effect. - Activities: bounded in-memory host events and request receipts. Request diff --git a/mobile/STANDALONE.md b/mobile/STANDALONE.md new file mode 100644 index 000000000..2b632afa5 --- /dev/null +++ b/mobile/STANDALONE.md @@ -0,0 +1,48 @@ +# Native standalone controller integration + +The user requires full native standalone operation. Offline drafts do not +satisfy that requirement. The Flutter app currently remains a remote plus +native authoring surface; this file records concrete progress and the remaining +integration, without claiming that a compiled library controls a board. + +## Shared core build evidence + +The existing `Tools/Controller/cmd/controller-cabi` successfully builds with +Go 1.26.5, `GOOS=android`, `GOARCH=arm64`, `CGO_ENABLED=1`, +`-tags controllerlib -buildmode=c-shared`. The installed Android NDK +28.2.13676358's Clang targeted Android API 24. ELF inspection confirms an +AArch64 shared object. No SDK/library upgrade, Android root grant, board write +or standalone runtime launch was needed for this verification. + +This C API already owns the production Go Host, RPC dispatcher, profile, +configuration, effects and controller domains. Integrate it rather than +duplicating that coordinator in Dart or restoring the donor ESP protocol. +Firmware stays the one shared production firmware; Android varies the native +entry point, device transport and packaging. + +## Required integration before standalone acceptance + +- Package the reproducibly built native core and its existing notices. Record + exact source/binary identity and prove that it loads/runs on each device. +- Give it a dedicated app-private configuration/data root. Never point tests + at the user's existing app configuration or start automatic board discovery + during a native-core smoke check. +- Route native RPC, subscriptions and artifacts through a private app-owned + endpoint. PCController's trusted loopback model alone is not a per-Android-UID + boundary. Retain the mediated, authenticated LocalAutomation interface for + ADB/Termux/Tasker clients; do not publish a second unrestricted board proxy. +- Implement explicit foreground service/lifecycle ownership and a verified + graceful shutdown path. Android SAF dialogs must not destroy a programming + coordinator or silently replace the pinned board owner. +- Acquire Android USB permission for an actually discovered supported device; + supply serial byte I/O/reset metadata as a HAL/transport adapter. Keep the + current framed protocol, semantic profile, timing and safety admission. +- Reconcile physical identity, busy/programming latch, capability and reconnect + state. Lost acknowledgements never replay mutations or claim success. +- Prove actual monitoring, output/front-panel/audio/display timing, firmware + backup/upload/readback and recovery through that Android-owned route. +- Complete local Pealayer/media ownership where required by standalone mode; + a remote playback controller is not a complete standalone player. + +Android USB access, native programming/toolchain packaging and full standalone +Pealayer are not delivered by preview 0.1.3. They remain explicit goal work. diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 063720359..d41abc0ff 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -18,6 +18,7 @@ import 'unified/automation.dart'; import 'unified/preferences.dart'; import 'unified/buzzer_mirror.dart'; import 'unified/channel_editor.dart'; +import 'unified/cue_editor.dart'; void main() => runApp(const UnifiedControllerApp()); @@ -2072,9 +2073,7 @@ class _ControllerHomeState extends State title: Text('${cue['name'] ?? cue['reference'] ?? cue['id']}'), subtitle: Text('${cue['start_time_ms']} ms'), onTap: player.fresh && !busy - ? () => unawaited( - playerCommand('timeline.cue.edit', {'instance_id': cue['id']}), - ) + ? () => unawaited(editPlayerCue(cue)) : null, ), action( @@ -2085,6 +2084,65 @@ class _ControllerHomeState extends State : null, ), ]); + Future editPlayerCue(Json cue) async { + if (!player.fresh || + busy || + cue['id'] is! String || + cue['start_time_ms'] is! int || + cue['duration_ms'] is! int) { + return; + } + final owner = player.targetStamp, peer = player.transport; + final fingerprint = jsonEncode(cue); + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => CueEditor( + cue: cue, + save: (start, length) async { + final current = objects(player.state['cues']) + .where((v) => v['id'] == cue['id']) + .firstOrNull; + if (!player.fresh || + player.targetStamp != owner || + peer == null || + current == null || + jsonEncode(current) != fingerprint) { + throw const Rejected( + 'Cue or player changed. Reopen the cue to edit its current values.', + ); + } + if (length != cue['duration_ms'] && current['resizable'] != true) { + throw const Rejected('This cue has an intrinsic duration.'); + } + setState(() { + activeRequests++; + busy = true; + }); + try { + await peer.rpc( + length == cue['duration_ms'] + ? 'timeline.cue.place' + : 'effect_cue.update', + { + 'instance_id': cue['id'], + 'start_time_ms': start, + if (length != cue['duration_ms']) 'duration_ms': length, + }, + ); + } finally { + if (mounted) { + setState(() { + activeRequests--; + busy = activeRequests > 0; + }); + } + } + }, + ), + ); + } + Widget peers() => card('Connected peers', Icons.hub_outlined, [ const Text( 'Select a registered Pealayer instance to control it through PCController. Commands retain the host’s authority and delivery checks.', @@ -2534,7 +2592,7 @@ class _ControllerHomeState extends State Future _automationCall(String method, Json params) async { switch (method) { case 'app.ping': - return {'ok': true, 'app': 'PCController', 'version': '0.1.2'}; + return {'ok': true, 'app': 'PCController', 'version': '0.1.3'}; case 'app.capabilities': return { 'jsonrpc': '2.0', diff --git a/mobile/lib/unified/cue_editor.dart b/mobile/lib/unified/cue_editor.dart new file mode 100644 index 000000000..44033fd89 --- /dev/null +++ b/mobile/lib/unified/cue_editor.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'transport.dart' show Json; + +int? cueSecondsToMs(String text) { + final match = RegExp(r'^\+?(?:(\d+)(?:\.(\d*))?|\.(\d+))$') + .firstMatch(text.trim()); + if (match == null) return null; + final fraction = (match.group(2) ?? match.group(3) ?? '').padRight(4, '0'); + final milliseconds = + BigInt.parse(match.group(1) ?? '0') * BigInt.from(1000) + + BigInt.from(int.parse(fraction.substring(0, 3))) + + BigInt.from(int.parse(fraction[3]) >= 5 ? 1 : 0); + return milliseconds <= BigInt.parse('9223372036854775807') + ? milliseconds.toInt() + : null; +} + +String cueStartText(int milliseconds) { + final fraction = (milliseconds % 1000) + .toString() + .padLeft(3, '0') + .replaceFirst(RegExp(r'0+$'), ''); + return '${milliseconds ~/ 1000}${fraction.isEmpty ? '' : '.$fraction'}'; +} + +class CueEditor extends StatefulWidget { + const CueEditor({super.key, required this.cue, required this.save}); + final Json cue; + final Future Function(int startMs, int durationMs) save; + @override + State createState() => _CueEditorState(); +} + +class _CueEditorState extends State { + final form = GlobalKey(); + late final start = TextEditingController( + text: cueStartText(widget.cue['start_time_ms'] as int), + ); + late final duration = TextEditingController( + text: '${widget.cue['duration_ms']}', + ); + bool saving = false; + String? error; + @override + void dispose() { + start.dispose(); + duration.dispose(); + super.dispose(); + } + + bool get dirty => + start.text != cueStartText(widget.cue['start_time_ms'] as int) || + duration.text != '${widget.cue['duration_ms']}'; + Future close() async { + if (saving) return; + if (dirty) { + final discard = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Unsaved cue'), + content: const Text('Discard cue edits?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Keep editing'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Discard'), + ), + ], + ), + ); + if (discard != true) return; + } + if (mounted) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(close()); + }, + child: AlertDialog( + title: Row( + children: [ + Expanded( + child: Text( + '${widget.cue['name']}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: 'Close', + onPressed: saving ? null : () => unawaited(close()), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: start, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Start (seconds)', + ), + validator: (value) { + return cueSecondsToMs(value ?? '') == null + ? 'Enter a non-negative time in seconds' + : null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: duration, + keyboardType: TextInputType.number, + enabled: !saving && widget.cue['resizable'] == true, + decoration: const InputDecoration(labelText: 'Duration (ms)'), + validator: (value) { + final number = int.tryParse(value ?? ''); + return widget.cue['resizable'] == true && + (number == null || number < 1 || number > 86400000) + ? 'Enter a duration from 1 ms to 24 hours' + : null; + }, + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + if (saving) + const Padding( + padding: EdgeInsets.only(top: 16), + child: LinearProgressIndicator(), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: saving ? null : () => unawaited(close()), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving + ? null + : () async { + if (!form.currentState!.validate()) return; + final startMs = cueSecondsToMs(start.text)!; + final durationMs = int.parse(duration.text); + if (startMs == widget.cue['start_time_ms'] && + durationMs == widget.cue['duration_ms']) { + Navigator.pop(context); + return; + } + setState(() { + saving = true; + error = null; + }); + try { + await widget.save(startMs, durationMs); + if (context.mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + setState(() { + saving = false; + error = '$e'; + }); + } + } + }, + child: Text(saving ? 'Saving…' : 'Save'), + ), + ], + ), + ); +} diff --git a/mobile/lib/unified/effect_editor.dart b/mobile/lib/unified/effect_editor.dart index 0d1a83378..43e2ac55f 100644 --- a/mobile/lib/unified/effect_editor.dart +++ b/mobile/lib/unified/effect_editor.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'transport.dart'; +import 'sequence_timeline.dart'; /// Pure native authoring; never creates a connection or actuates an output. class EffectEditor extends StatefulWidget { @@ -19,6 +20,7 @@ class _EffectEditorState extends State { late Json effect; late TextEditingController name, id, category, raw; int selected = -1; + int timingRevision = 0; bool saving = false; String? error; @override @@ -92,7 +94,7 @@ class _EffectEditorState extends State { SizedBox( width: 140, child: TextFormField( - key: ValueKey('$selected/$key'), + key: ValueKey('$selected/$key/$timingRevision'), initialValue: '${value / scale}', keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration(labelText: label), @@ -183,37 +185,27 @@ class _EffectEditorState extends State { ), const SizedBox(height: 16), if (effect['kind'] == 'sequence') ...[ - Text('Timeline · ${steps.length} steps'), - const SizedBox(height: 8), - for (var index = 0; index < steps.length; index++) - InkWell( - onTap: () => setState(() => selected = index), - child: SizedBox( - height: 36, - child: Row( - children: [ - SizedBox( - width: 96, - child: Text( - '${index + 1} · ${steps[index]['kind']}', - overflow: TextOverflow.ellipsis, - ), - ), - Expanded( - child: CustomPaint( - size: const Size(double.infinity, 32), - painter: StepPainter( - steps[index], - index == selected, - sequenceDuration(steps), - Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ), - ), - ), + SequenceTimeline( + steps: steps, + selected: selected, + enabled: !saving, + select: (index) => setState(() => selected = index), + edit: (index, start, length) { + final next = steps; + if (index < 0 || index >= next.length) return; + next[index] = { + ...next[index], + 'at_us': start, + 'duration_ms': ?length, + }; + setState(() { + timingRevision++; + effect['steps'] = next; + raw.text = const JsonEncoder.withIndent(' ') + .convert(effect); + }); + }, + ), const SizedBox(height: 12), Wrap( spacing: 8, @@ -300,6 +292,89 @@ class _EffectEditorState extends State { onChanged: (text) => changeStep('text', text), ), ), + if (current['kind'] == 'rgb') ...[ + const SizedBox(height: 12), + for (final entry in [ + ('red', 'Red', Colors.red), + ('green', 'Green', Colors.green), + ('blue', 'Blue', Colors.blue), + ( + 'brightness', + 'Brightness', + Theme.of(context).colorScheme.primary, + ), + ]) + Row( + children: [ + SizedBox(width: 86, child: Text(entry.$2)), + Expanded( + child: Slider( + min: 0, + max: 255, + divisions: 255, + activeColor: entry.$3, + value: + (current[entry.$1] is num && + (current[entry.$1] as num) + .isFinite + ? (current[entry.$1] as num) + .toDouble() + : 0) + .clamp(0, 255) + .toDouble(), + label: '${current[entry.$1] ?? 0}', + onChanged: saving + ? null + : (v) => + changeStep(entry.$1, v.round()), + ), + ), + SizedBox( + width: 32, + child: Text( + '${current[entry.$1] ?? 0}', + textAlign: TextAlign.end, + ), + ), + ], + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final color in [ + const Color(0xffff4040), + const Color(0xff40dd80), + const Color(0xff4080ff), + const Color(0xffaa70dd), + Colors.white, + ]) + IconButton( + tooltip: + '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}', + icon: Icon(Icons.circle, color: color), + onPressed: saving + ? null + : () { + final next = steps; + next[selected] = { + ...next[selected], + 'red': (color.r * 255).round(), + 'green': (color.g * 255).round(), + 'blue': (color.b * 255).round(), + }; + setState(() { + effect['steps'] = next; + raw.text = + const JsonEncoder.withIndent( + ' ', + ).convert(effect); + }); + }, + ), + ], + ), + ], TextButton.icon( onPressed: () => setState(() { final next = steps; diff --git a/mobile/lib/unified/sequence_timeline.dart b/mobile/lib/unified/sequence_timeline.dart new file mode 100644 index 000000000..fa8029912 --- /dev/null +++ b/mobile/lib/unified/sequence_timeline.dart @@ -0,0 +1,429 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'transport.dart' show Json; + +double stepStartMs(Json step) => + boundedStepNumber(step['at_us'], 0, 4294967295) / 1000; +double stepLengthMs(Json step) => + boundedStepNumber(step['duration_ms'], 0, 4294967); +double boundedStepNumber(Object? value, double min, double max) => + value is num && value.isFinite ? value.toDouble().clamp(min, max) : min; +double timelineExtent(List steps) => steps.fold( + 1000, + (end, step) => math.max( + end, + stepStartMs(step) + + stepLengthMs(step) + + (boundedStepNumber(step['repeat_count'], 1, 64) - 1) * + boundedStepNumber(step['repeat_interval_ms'], 0, 4294967), + ), +); + +/// Native authoring only. Parent retains descriptors and commits a single timing +/// edit at release. A changed descriptor invalidates a drag instead of retargeting it. +class SequenceTimeline extends StatefulWidget { + const SequenceTimeline({ + super.key, + required this.steps, + required this.selected, + required this.select, + required this.edit, + this.enabled = true, + }); + final List steps; + final int selected; + final ValueChanged select; + final void Function(int index, int startUs, int? durationMs) edit; + final bool enabled; + @override + State createState() => _SequenceTimelineState(); +} + +class _SequenceTimelineState extends State { + final scroll = ScrollController(); + double zoom = 1; + bool snap = true; + int? dragging; + int initialStepCount = 0; + String? fingerprint; + double delta = 0, originalStart = 0, originalLength = 0; + double? draftStart, draftLength; + bool resizing = false; + @override + void dispose() { + scroll.dispose(); + super.dispose(); + } + + void begin(int index, bool resize) { + if (!widget.enabled) return; + dragging = index; + initialStepCount = widget.steps.length; + fingerprint = jsonEncode(widget.steps[index]); + originalStart = stepStartMs(widget.steps[index]); + originalLength = stepLengthMs(widget.steps[index]); + delta = 0; + resizing = resize; + widget.select(index); + setState(() { + draftStart = originalStart; + draftLength = originalLength; + }); + } + + void update(DragUpdateDetails details, double pxPerMs) { + if (dragging == null || !widget.enabled) return; + delta += details.delta.dx / pxPerMs; + double quantize(double value) => snap ? (value / 10).round() * 10.0 : value; + setState(() { + if (resizing) { + draftLength = quantize(originalLength + delta).clamp(1, 4294967); + } else { + draftStart = quantize(originalStart + delta).clamp(0, 4294967.295); + } + }); + } + + void finish() { + final index = dragging, start = draftStart, length = draftLength; + if (widget.enabled && + index != null && + index < widget.steps.length && + initialStepCount == widget.steps.length && + start != null && + length != null && + fingerprint == jsonEncode(widget.steps[index])) { + widget.edit( + index, + (start * 1000).round().clamp(0, 4294967295), + resizing ? length.round() : null, + ); + } + cancel(); + } + + void cancel() { + if (mounted) { + setState(() { + dragging = null; + fingerprint = null; + draftStart = draftLength = null; + }); + } + } + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, bounds) { + const labelWidth = 86.0; + final viewport = math.max(120.0, bounds.maxWidth - labelWidth); + final extent = timelineExtent(widget.steps); + final pxPerMs = viewport / (extent + 200) * zoom; + final canvasWidth = math.max(viewport, (extent + 200) * pxPerMs); + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + children: [ + Text('Timeline', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(width: 8), + IconButton( + tooltip: 'Zoom out', + onPressed: zoom > 1 + ? () => setState(() => zoom = math.max(1, zoom / 1.5)) + : null, + icon: const Icon(Icons.zoom_out), + ), + IconButton( + tooltip: 'Zoom in', + onPressed: zoom < 64 + ? () => setState(() => zoom = math.min(64, zoom * 1.5)) + : null, + icon: const Icon(Icons.zoom_in), + ), + TextButton( + onPressed: () { + cancel(); + setState(() => zoom = 1); + if (scroll.hasClients) scroll.jumpTo(0); + }, + child: const Text('Fit'), + ), + FilterChip( + label: const Text('Snap 10 ms'), + selected: snap, + onSelected: (v) => setState(() => snap = v), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainer, + border: Border.all(color: colors.outlineVariant), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: SingleChildScrollView( + child: Directionality( + textDirection: TextDirection.ltr, + child: Scrollbar( + controller: scroll, + thumbVisibility: zoom > 1, + child: SingleChildScrollView( + controller: scroll, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: labelWidth + canvasWidth, + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: labelWidth), + SizedBox( + width: canvasWidth, + height: 32, + child: CustomPaint( + painter: TimelineRuler( + pxPerMs, + colors.outlineVariant, + Theme.of(context).textTheme.labelSmall! + .copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + if (widget.steps.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Add a step to start your sequence', + ), + ), + for ( + var index = 0; + index < widget.steps.length; + index++ + ) + row( + context, + index, + labelWidth, + canvasWidth, + pxPerMs, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + + Widget row( + BuildContext context, + int index, + double labelWidth, + double width, + double scale, + ) { + final step = widget.steps[index], colors = Theme.of(context).colorScheme; + final active = index == widget.selected; + final start = dragging == index ? draftStart! : stepStartMs(step); + final length = dragging == index ? draftLength! : stepLengthMs(step); + final cueWidth = math.max(44.0, length * scale); + final repeats = boundedStepNumber(step['repeat_count'], 1, 64).toInt(); + final interval = boundedStepNumber(step['repeat_interval_ms'], 0, 4294967); + final color = switch (step['kind']) { + 'buzzer' => const Color(0xff9c82dc), + 'display' => const Color(0xff63aea5), + 'rgb' => const Color(0xffdf9d69), + _ => colors.primary, + }; + return Container( + height: 52, + decoration: BoxDecoration( + color: active ? colors.primary.withValues(alpha: .06) : null, + border: Border( + top: BorderSide(color: colors.outlineVariant.withValues(alpha: .5)), + ), + ), + child: Row( + children: [ + SizedBox( + width: labelWidth, + child: InkWell( + onTap: () => widget.select(index), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text( + '${index + 1} · ${step['kind']}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(fontWeight: active ? FontWeight.w700 : null), + ), + ), + ), + ), + SizedBox( + width: width, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (var repeat = 1; repeat < repeats; repeat++) + Positioned( + left: (start + repeat * interval) * scale, + top: 10, + width: cueWidth, + height: 32, + child: DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: .18), + borderRadius: BorderRadius.circular(5), + ), + ), + ), + Positioned( + left: start * scale, + top: 4, + width: cueWidth, + height: 44, + child: Semantics( + selected: active, + label: + 'Step ${index + 1}, ${step['kind']}, ${(start / 1000).toStringAsFixed(2)} seconds', + child: GestureDetector( + key: ValueKey('timeline-step-$index'), + behavior: HitTestBehavior.opaque, + onTap: () => widget.select(index), + onHorizontalDragStart: widget.enabled + ? (_) => begin(index, false) + : null, + onHorizontalDragUpdate: widget.enabled + ? (d) => update(d, scale) + : null, + onHorizontalDragEnd: widget.enabled + ? (_) => finish() + : null, + onHorizontalDragCancel: cancel, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: active ? .9 : .65), + borderRadius: BorderRadius.circular(5), + border: active + ? Border.all( + color: colors.onSurface, + width: 1.5, + ) + : null, + ), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '${(start / 1000).toStringAsFixed(2)}s', + maxLines: 1, + overflow: TextOverflow.clip, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: Colors.white), + ), + ), + ), + ), + ), + ), + if (active && step['duration_ms'] is num) + Positioned( + left: start * scale + cueWidth - 6, + top: 4, + width: 44, + height: 44, + child: Tooltip( + message: 'Resize step duration', + child: GestureDetector( + key: ValueKey('timeline-resize-$index'), + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: widget.enabled + ? (_) => begin(index, true) + : null, + onHorizontalDragUpdate: widget.enabled + ? (d) => update(d, scale) + : null, + onHorizontalDragEnd: widget.enabled + ? (_) => finish() + : null, + onHorizontalDragCancel: cancel, + child: Align( + alignment: Alignment.centerLeft, + child: Icon( + Icons.drag_indicator, + size: 18, + color: colors.onSurface, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class TimelineRuler extends CustomPainter { + TimelineRuler(this.scale, this.color, this.style); + final double scale; + final Color color; + final TextStyle style; + @override + void paint(Canvas canvas, Size size) { + final raw = 100 / scale; + final power = math.pow(10, (math.log(raw) / math.ln10).floor()).toDouble(); + final tick = + (raw / power <= 2 + ? 2 + : raw / power <= 5 + ? 5 + : 10) * + power; + for (var ms = 0.0; ms * scale < size.width; ms += tick) { + final x = ms * scale; + canvas.drawLine(Offset(x, 25), Offset(x, 32), Paint()..color = color); + final text = TextPainter( + text: TextSpan( + text: '${(ms / 1000).toStringAsFixed(ms < 1000 ? 2 : 1)}s', + style: style, + ), + textDirection: TextDirection.ltr, + )..layout(); + text.paint(canvas, Offset(x + 4, 3)); + } + } + + @override + bool shouldRepaint(TimelineRuler old) => + old.scale != scale || old.color != color || old.style != style; +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 068d64ec1..fc6cd6b30 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.2+3 +version: 0.1.3+4 environment: sdk: ^3.13.3 diff --git a/mobile/test/cue_editor_test.dart b/mobile/test/cue_editor_test.dart new file mode 100644 index 000000000..355786720 --- /dev/null +++ b/mobile/test/cue_editor_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pccontroller_mobile/unified/cue_editor.dart'; +import 'package:pccontroller_mobile/unified/transport.dart'; + +void main() { + final cue = { + 'id': '00000000-0000-4000-8000-000000000001', + 'name': 'Lighting cue', + 'start_time_ms': 1200, + 'duration_ms': 1000, + 'resizable': true, + }; + Future open( + WidgetTester tester, + Json value, + Future Function(int, int) save, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => CueEditor(cue: value, save: save), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + test( + 'seconds input keeps exact integer milliseconds and rejects invalid times', + () { + expect(cueSecondsToMs('1.2345'), 1235); + expect(cueSecondsToMs('.001'), 1); + expect(cueSecondsToMs('9223372036854775.807'), 9223372036854775807); + expect(cueStartText(9223372036854775807), '9223372036854775.807'); + for (final invalid in [ + '-1', + 'NaN', + 'Infinity', + '1e400', + '9223372036854775.808', + ]) { + expect(cueSecondsToMs(invalid), null); + } + }, + ); + testWidgets('opening or unchanged Save never sends a mutation', ( + tester, + ) async { + var calls = 0; + await open(tester, cue, (_, _) async { + calls++; + }); + expect(calls, 0); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(calls, 0); + expect(find.byType(CueEditor), findsNothing); + }); + testWidgets( + 'invalid duration cannot save and corrected fields send one typed edit', + (tester) async { + final calls = <(int, int)>[]; + await open(tester, cue, (start, duration) async { + calls.add((start, duration)); + }); + await tester.enterText(find.byType(TextFormField).last, '0'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(calls, isEmpty); + expect( + find.text('Enter a duration from 1 ms to 24 hours'), + findsOneWidget, + ); + await tester.enterText(find.byType(TextFormField).first, '2.125'); + await tester.enterText(find.byType(TextFormField).last, '1500'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(calls, [(2125, 1500)]); + expect(find.byType(CueEditor), findsNothing); + }, + ); + testWidgets( + 'intrinsic duration is read-only and failed acknowledgement keeps edits', + (tester) async { + await open(tester, {...cue, 'resizable': false}, (_, _) async { + throw const Rejected('Acknowledgement unavailable'); + }); + expect( + tester.widget(find.byType(TextFormField).last).enabled, + false, + ); + await tester.enterText(find.byType(TextFormField).first, '2'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('Acknowledgement unavailable'), findsOneWidget); + expect(find.byType(CueEditor), findsOneWidget); + expect( + tester + .widget(find.byType(TextFormField).first) + .controller! + .text, + '2', + ); + }, + ); + testWidgets( + 'Close confirms dirty edits and Keep editing preserves them without a request', + (tester) async { + var calls = 0; + await open(tester, cue, (_, _) async { + calls++; + }); + await tester.enterText(find.byType(TextFormField).first, '2'); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.text('Unsaved cue'), findsOneWidget); + await tester.tap(find.text('Keep editing')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(TextFormField).first) + .controller! + .text, + '2', + ); + expect(calls, 0); + }, + ); +} diff --git a/mobile/test/sequence_timeline_test.dart b/mobile/test/sequence_timeline_test.dart new file mode 100644 index 000000000..75a670376 --- /dev/null +++ b/mobile/test/sequence_timeline_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pccontroller_mobile/unified/sequence_timeline.dart'; +import 'package:pccontroller_mobile/unified/transport.dart'; + +void main() { + Widget screen(List steps, void Function(int, int, int?) edit) => + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 640, + child: SequenceTimeline( + steps: steps, + selected: 0, + select: (_) {}, + edit: edit, + ), + ), + ), + ); + final step = { + 'kind': 'display', + 'at_us': 200000, + 'duration_ms': 1000, + 'text': 'Example', + 'unknown_metadata': {'preserve': true}, + }; + + testWidgets('drag commits one snapped start edit without altering duration', ( + tester, + ) async { + final changes = <(int, int, int?)>[]; + await tester.pumpWidget( + screen([step], (i, at, duration) => changes.add((i, at, duration))), + ); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('timeline-step-0'))), + ); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + expect(changes, isEmpty); + await gesture.up(); + await tester.pump(); + expect(changes, hasLength(1)); + expect(changes.single.$2, greaterThan(200000)); + expect(changes.single.$2 % 10000, 0); + expect(changes.single.$3, null); + expect(step['unknown_metadata'], {'preserve': true}); + expect(tester.takeException(), null); + }); + testWidgets( + 'duration resize keeps the original start and produces one edit', + (tester) async { + final changes = <(int, int, int?)>[]; + await tester.pumpWidget( + screen([step], (i, at, duration) => changes.add((i, at, duration))), + ); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('timeline-resize-0'))), + ); + await gesture.moveBy(const Offset(-20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(-80, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(changes, hasLength(1)); + expect(changes.single.$2, 200000); + expect(changes.single.$3, inInclusiveRange(1, 990)); + expect(changes.single.$3! % 10, 0); + expect(tester.takeException(), null); + }, + ); + testWidgets( + 'descriptor changed during drag cancels instead of editing replacement', + (tester) async { + final changes = <(int, int, int?)>[]; + void record(int i, int at, int? duration) => + changes.add((i, at, duration)); + await tester.pumpWidget(screen([step], record)); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('timeline-step-0'))), + ); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + await tester.pumpWidget( + screen([ + {...step, 'text': 'Another step'}, + ], record), + ); + await gesture.up(); + await tester.pump(); + expect(changes, isEmpty); + expect(tester.takeException(), null); + }, + ); + testWidgets('removing an identical step during a drag cancels its index', ( + tester, + ) async { + final changes = <(int, int, int?)>[]; + void record(int i, int at, int? duration) => changes.add((i, at, duration)); + await tester.pumpWidget(screen([step, step], record)); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('timeline-step-0'))), + ); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + await tester.pumpWidget(screen([step], record)); + await gesture.up(); + await tester.pump(); + expect(changes, isEmpty); + }); + for (final width in [288.0, 920.0]) { + testWidgets('timeline zoom and repeat previews fit width $width', ( + tester, + ) async { + tester.view.physicalSize = Size(width + 32, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: width, + child: SequenceTimeline( + steps: [ + {...step, 'repeat_count': 3, 'repeat_interval_ms': 1500}, + ], + selected: 0, + select: (_) {}, + edit: (_, _, _) {}, + ), + ), + ), + ), + ); + await tester.tap(find.byTooltip('Zoom in')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fit')); + await tester.pumpAndSettle(); + expect(tester.takeException(), null); + }); + } +} diff --git a/mobile/test/unified_acceptance_test.dart b/mobile/test/unified_acceptance_test.dart index a4c301a22..a69635b5d 100644 --- a/mobile/test/unified_acceptance_test.dart +++ b/mobile/test/unified_acceptance_test.dart @@ -10,6 +10,56 @@ import 'package:pccontroller_mobile/unified/live_widgets.dart'; import 'package:pccontroller_mobile/unified/automation.dart'; void main() { + testWidgets('native cue editor refuses a changed cue owner before any RPC', ( + tester, + ) async { + await tester.pumpWidget(const UnifiedControllerApp(restore: false)); + final dynamic home = tester.state(find.byType(ControllerHome)); + final PeerSession peer = home.player; + var calls = 0; + peer.transport = PeerTransport( + 'http://fixture', + client: MockClient((request) async { + calls++; + return http.Response('{}', 200); + }), + ); + final cue = { + 'id': '00000000-0000-4000-8000-000000000001', + 'name': 'Fixture cue', + 'start_time_ms': 1000, + 'duration_ms': 500, + 'resizable': true, + }; + peer.online = true; + peer.received = DateTime.now(); + peer.state = { + 'cues': [cue], + }; + final open = home.editPlayerCue(cue) as Future; + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, '2'); + peer.state = { + 'cues': [ + {...cue, 'start_time_ms': 3000}, + ], + }; + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect( + find.text( + 'Cue or player changed. Reopen the cue to edit its current values.', + ), + findsOneWidget, + ); + expect(calls, 0); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + await open; + await tester.pumpWidget(const SizedBox()); + }); testWidgets( 'live slider keeps drag preview and sends one request only on release', (tester) async { From 4bfe948f24ab2ea82c3d19f635b898b28197224c Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 03:11:42 +0330 Subject: [PATCH 15/72] feat: verified app discovery and consented peer APK upgrades [skip ci] --- AGENTS.md | 12 + .../Controller/cmd/controller/network_cli.go | 19 +- .../internal/appconfig/integrations.go | 2 + .../internal/discovery/discovery.go | 58 ++- Tools/Controller/internal/discovery/mndp.go | 149 +++++++ Tools/Controller/internal/discovery/public.go | 37 ++ .../Controller/internal/hostbridge/manager.go | 3 +- Tools/Controller/internal/ipcjson/ipc.go | 10 +- Tools/Controller/internal/ipcjson/upnp.go | 6 +- .../Controller/web/src/advanced-workbench.tsx | 8 +- mobile/README.md | 46 ++ .../pakar/pakarcontroller/DiscoveryChannel.kt | 88 +++- .../pakar/pakarcontroller/UpgradeChannel.kt | 226 ++++++++++ mobile/lib/discovery.dart | 246 +++++++++-- mobile/lib/unified/app_upgrade.dart | 362 ++++++++++++++++ mobile/lib/unified/automation.dart | 24 +- mobile/lib/unified/peer_upgrades.dart | 405 ++++++++++++++++++ mobile/test/bridge_discovery_test.dart | 38 +- 18 files changed, 1666 insertions(+), 73 deletions(-) create mode 100644 Tools/Controller/internal/discovery/mndp.go create mode 100644 mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt create mode 100644 mobile/lib/unified/app_upgrade.dart create mode 100644 mobile/lib/unified/peer_upgrades.dart diff --git a/AGENTS.md b/AGENTS.md index 753d44d57..b465aefbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,3 +30,15 @@ ## Digitalogic interface design Before implementing or reviewing any user-facing interface, read and follow [Contextual UI and no narration](docs/CONTEXTUAL-UI.md). This standing ecosystem contract covers state-driven rendering, progressive disclosure, semantic actions, conditional affordances and stable layout. Apply it across related controls, preserving existing safety and authorization requirements. + +## Unified controller application - mandatory acceptance + +- The current basic Flutter design is rejected. Build one expert utility workspace, not separate PCController/Pealayer product tabs. Organize by useful activities and current capabilities; peers and branding are contextual identities, not duplicate navigation hierarchies. +- Use one contextual connection toggle per peer (Connect, connecting/cancel, or Disconnect). Hide dormant access-token inputs while the current products do not require authentication. Defer a complete multi-step authentication/authorization flow; do not introduce token placeholders or a partial login design. +- Omit unavailable-only cards and irrelevant controls. Never publish unknown measurements as zero. Remove filler narration such as "last values unavailable" and boilerplate explanations of ordinary behavior. Preserve concise, actionable failures, stale-state/safety indicators and outcome-unknown errors. +- Use compact, professionally composed phone/tablet layouts: aligned headers, meaningful cards, contextual dialogs, square accessible icon actions, semantic colors/pills, real live charts and efficient space. Rich styling must remain coherent, legible and useful; neither oversized generic buttons nor a plain basic navigation shell is accepted. +- Refresh from authoritative events, coalesce unchanged work and retain last good state. Do not add per-frame network probes, persistence, synthetic telemetry or automatic mutation retries. +- Preferences own appearance, density, branding, palettes (including the original PakarController schemes), activity/task icon and one or both launcher identities. Both launcher entries must open the same unified application. Check actual Android icon/alias behavior rather than claiming a Dart preference changed OS state. +- DNS-SD/mDNS, SSDP and MNDP must be implemented/coordinated across PCController, Pealayer and Flutter, with real service identity and endpoint verification. Auto-connect valid discovered peers according to saved user intent, but never treat a raw advertisement as proof, silently replace a live selected peer, take board authority or actuate outputs. +- Android peers must support bounded, verified app-package upload/download and upgrades through the existing IPC/RPC transport. Preserve package identity/signature checks, unknown-outcome handling and Android user-consent/install permissions; no silent install or arbitrary path/URL proxy. +- Source/build/device screenshots are separate from live transport, physical/audio acceptance and David's design acceptance. Keep rejected/incomplete gates open, preserve concurrent authoring work and record source provenance before deployment. diff --git a/Tools/Controller/cmd/controller/network_cli.go b/Tools/Controller/cmd/controller/network_cli.go index 27a04d538..41ad9acd2 100644 --- a/Tools/Controller/cmd/controller/network_cli.go +++ b/Tools/Controller/cmd/controller/network_cli.go @@ -215,7 +215,7 @@ func runNetwork(args []string, stdout, stderr io.Writer, store *appconfig.Store) flags := flag.NewFlagSet("network advertise", flag.ContinueOnError) flags.SetOutput(stderr) enabled := flags.Bool("enabled", true, "enable or disable advertisement") - protocols := flags.String("protocols", "dns-sd,ssdp,upnp,ws-discovery,broadcast,netbios", "comma-separated protocols") + protocols := flags.String("protocols", "dns-sd,ssdp,upnp,ws-discovery,broadcast,netbios,mndp", "comma-separated protocols") instance := flags.String("instance", "", "advertised instance name") broadcastPort := flags.Int("broadcast-port", discovery.BroadcastPort, "UDP broadcast discovery port") if err := flags.Parse(args[1:]); err != nil { @@ -234,6 +234,7 @@ func runNetwork(args []string, stdout, stderr io.Writer, store *appconfig.Store) configured.SSDPEnabled, configured.UPnPEnabled = options.SSDP, options.UPnP configured.WSDiscoveryEnabled, configured.BroadcastEnabled = options.WSDiscovery, options.Broadcast configured.NetBIOSEnabled = options.NetBIOS + configured.MNDPEnabled = options.MNDP } primaryContext, stopPrimary := context.WithTimeout(context.Background(), time.Second) havePrimary := primaryAvailable(primaryContext) @@ -259,7 +260,7 @@ func runNetwork(args []string, stdout, stderr io.Writer, store *appconfig.Store) case "discover", "list": flags := flag.NewFlagSet("network discover", flag.ContinueOnError) flags.SetOutput(stderr) - protocols := flags.String("protocols", "dns-sd,ssdp,upnp,ws-discovery,broadcast,netbios", "comma-separated protocols") + protocols := flags.String("protocols", "dns-sd,ssdp,upnp,ws-discovery,broadcast,netbios,mndp", "comma-separated protocols") timeout := flags.Duration("timeout", 3*time.Second, "discovery timeout") if err := flags.Parse(args[1:]); err != nil { return err @@ -284,7 +285,7 @@ func runNetwork(args []string, stdout, stderr io.Writer, store *appconfig.Store) "timeout_ms": optionsTimeoutMilliseconds(scanDuration), "mdns": options.MDNS, "dns_sd": options.DNSSD, "ssdp": options.SSDP, "upnp": options.UPnP, "ws_discovery": options.WSDiscovery, - "broadcast": options.Broadcast, "netbios": options.NetBIOS, + "broadcast": options.Broadcast, "netbios": options.NetBIOS, "mndp": options.MNDP, }, &instances) stopScan() if err != nil { @@ -303,7 +304,7 @@ func runNetwork(args []string, stdout, stderr io.Writer, store *appconfig.Store) flags.SetOutput(stderr) target := flags.String("target", "", "discovered name, hostname, instance id, or address") tokenRef := flags.String("token-ref", "", "OS-vault or environment bearer-token reference") - protocols := flags.String("protocols", "dns-sd,ssdp,upnp,ws-discovery,broadcast,netbios", "comma-separated protocols") + protocols := flags.String("protocols", "dns-sd,ssdp,upnp,ws-discovery,broadcast,netbios,mndp", "comma-separated protocols") timeout := flags.Duration("timeout", 15*time.Second, "discovery and connection timeout") origin := flags.String("origin", "", "allowed HTTP origin (local hostname by default)") if err := flags.Parse(args[1:]); err != nil { @@ -464,6 +465,7 @@ func parseDiscoveryOptions(protocols string) (discovery.Options, error) { case "all": options.MDNS, options.DNSSD, options.SSDP, options.UPnP = true, true, true, true options.WSDiscovery, options.Broadcast, options.NetBIOS = true, true, true + options.MNDP = true case "mdns", "dns-sd", "dnssd": options.MDNS, options.DNSSD = true, true case "ssdp": @@ -476,6 +478,8 @@ func parseDiscoveryOptions(protocols string) (discovery.Options, error) { options.Broadcast = true case "netbios", "nbns": options.NetBIOS = true + case "mndp": + options.MNDP = true case "": default: return discovery.Options{}, fmt.Errorf("unsupported discovery protocol %q", protocol) @@ -504,6 +508,9 @@ func enabledProtocolNames(value appconfig.Discovery) []string { if value.NetBIOSEnabled { result = append(result, "netbios") } + if value.MNDPEnabled { + result = append(result, "mndp") + } return result } @@ -511,6 +518,10 @@ func resolveDiscoveredInstance(instances []discovery.Instance, target string) (d target = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(target), ".")) matches := make([]discovery.Instance, 0, 1) for _, instance := range instances { + // Other products are visible neighbours, not PCController bridge targets. + if instance.Product != "" && instance.Product != "pccontroller" { + continue + } values := []string{instance.Name, instance.Host, instance.USN} values = append(values, instance.Addresses...) if instance.Public != nil { diff --git a/Tools/Controller/internal/appconfig/integrations.go b/Tools/Controller/internal/appconfig/integrations.go index 324839fd6..1d7bc9ae8 100644 --- a/Tools/Controller/internal/appconfig/integrations.go +++ b/Tools/Controller/internal/appconfig/integrations.go @@ -207,6 +207,7 @@ type NotificationAction struct { } type Discovery struct { + MNDPEnabled bool `json:"mndp_enabled"` MDNSEnabled bool `json:"mdns_enabled"` DNSSDenabled bool `json:"dns_sd_enabled"` SSDPEnabled bool `json:"ssdp_enabled"` @@ -222,6 +223,7 @@ type Discovery struct { // available on first run. It does not enable remote control or weaken IPC auth. func DefaultDiscovery() Discovery { return Discovery{ + MNDPEnabled: true, MDNSEnabled: true, DNSSDenabled: true, SSDPEnabled: true, UPnPEnabled: true, WSDiscoveryEnabled: true, BroadcastEnabled: true, NetBIOSEnabled: true, BroadcastPort: 37889, diff --git a/Tools/Controller/internal/discovery/discovery.go b/Tools/Controller/internal/discovery/discovery.go index 954cce059..c88734d37 100644 --- a/Tools/Controller/internal/discovery/discovery.go +++ b/Tools/Controller/internal/discovery/discovery.go @@ -35,6 +35,8 @@ const ( ) type Instance struct { + Product string `json:"product,omitempty"` + Verified bool `json:"verified"` Protocol string `json:"protocol"` Protocols []string `json:"protocols,omitempty"` Sources []Source `json:"sources,omitempty"` @@ -61,6 +63,7 @@ type Options struct { WSDiscovery bool Broadcast bool NetBIOS bool + MNDP bool BroadcastPort int } @@ -89,6 +92,7 @@ type Advertiser struct { wsListener *net.UDPConn broadcastConn *net.UDPConn netbiosConn *net.UDPConn + mndpConn *net.UDPConn mu sync.RWMutex closed bool @@ -202,6 +206,17 @@ func AdvertiseWithOptions( result.active = append(result.active, "netbios") } } + if options.MNDP { + enabled++ + group, _ := net.ResolveUDPAddr("udp4", "224.0.0.88:5678") + connection, err := net.ListenMulticastUDP("udp4", nil, group) + if err != nil { + recordFailure("mndp", err) + } else { + result.mndpConn = connection + result.active = append(result.active, "mndp") + } + } if enabled > 0 && len(result.active) == 0 { cancel() messages := make([]error, 0, len(result.failures)) @@ -213,6 +228,10 @@ func AdvertiseWithOptions( go func() { defer close(result.done) var wait sync.WaitGroup + if result.mndpConn != nil { + wait.Add(1) + go func() { defer wait.Done(); runMNDPAdvertiser(ctx, result) }() + } if result.ssdp { wait.Add(1) go func() { defer wait.Done(); runSSDPAdvertiser(ctx, result) }() @@ -446,6 +465,10 @@ func DiscoverWithOptions(ctx context.Context, options Options) ([]Instance, erro wait.Add(1) go func() { defer wait.Done(); recordError(discoverNetBIOS(ctx, add)) }() } + if options.MNDP { + wait.Add(1) + go func() { defer wait.Done(); recordError(discoverMNDP(ctx, add)) }() + } wait.Wait() enrich.Wait() result := make([]Instance, 0, len(instances)) @@ -460,6 +483,26 @@ func DiscoverWithOptions(ctx context.Context, options Options) ([]Instance, erro } func browseMDNS(ctx context.Context, add func(Instance)) error { + var wait sync.WaitGroup + var first error + var mu sync.Mutex + for _, service := range []string{MDNSService, "_pealayer._tcp", "_digitalogic._tcp"} { + wait.Add(1) + go func(service string) { + defer wait.Done() + if err := browseMDNSService(ctx, service, add); err != nil { + mu.Lock() + if first == nil { + first = err + } + mu.Unlock() + } + }(service) + } + wait.Wait() + return first +} +func browseMDNSService(ctx context.Context, service string, add func(Instance)) error { resolver, err := zeroconf.NewResolver(nil) if err != nil { return fmt.Errorf("create mDNS resolver: %w", err) @@ -477,6 +520,7 @@ func browseMDNS(ctx context.Context, add func(Instance)) error { addresses = append(addresses, address.String()) } add(Instance{ + Product: map[string]string{MDNSService: "pccontroller", "_pealayer._tcp": "pealayer", "_digitalogic._tcp": "controller-mobile"}[service], Protocol: "mdns", Name: entry.Instance, Host: strings.TrimSuffix(entry.HostName, "."), Port: entry.Port, Addresses: addresses, TXT: append([]string(nil), entry.Text...), @@ -484,7 +528,7 @@ func browseMDNS(ctx context.Context, add func(Instance)) error { }) } }() - err = resolver.Browse(ctx, MDNSService, MDNSDomain, entries) + err = resolver.Browse(ctx, service, MDNSDomain, entries) if err != nil { close(entries) <-done @@ -651,7 +695,7 @@ func discoverSSDP(ctx context.Context, add func(Instance)) error { } request := strings.Join([]string{ "M-SEARCH * HTTP/1.1", "HOST: " + ssdpAddress, - `MAN: "ssdp:discover"`, "MX: 1", "ST: " + SSDPType, "", "", + `MAN: "ssdp:discover"`, "MX: 1", "ST: ssdp:all", "", "", }, "\r\n") if _, err := connection.WriteToUDP([]byte(request), address); err != nil { return fmt.Errorf("send SSDP search: %w", err) @@ -685,7 +729,8 @@ func parseSSDPResponse(data []byte, source *net.UDPAddr) (Instance, bool) { return Instance{}, false } _ = request.Body.Close() - if request.Header.Get("ST") != SSDPType { + product := map[string]string{SSDPType: "pccontroller", "urn:digitalogic-ir:device:Pealayer:1": "pealayer", "urn:digitalogic-ir:device:ControllerMobile:1": "controller-mobile"}[request.Header.Get("ST")] + if product == "" { return Instance{}, false } host := "" @@ -703,8 +748,13 @@ func parseSSDPResponse(data []byte, source *net.UDPAddr) (Instance, bool) { port = parsed.port } } + name := request.Header.Get("X-PCController-Name") + if name == "" { + name = request.Header.Get("X-Digitalogic-Name") + } return Instance{ - Protocol: "ssdp", Name: request.Header.Get("X-PCController-Name"), + Product: product, + Protocol: "ssdp", Name: name, Host: host, Port: port, Addresses: addresses, Location: request.Header.Get("Location"), PublicURL: request.Header.Get("X-PCController-Public"), USN: request.Header.Get("USN"), TXT: normalizeTXT(request.Header.Values("X-PCController-Meta")), SeenAt: time.Now(), diff --git a/Tools/Controller/internal/discovery/mndp.go b/Tools/Controller/internal/discovery/mndp.go new file mode 100644 index 000000000..6e1056e87 --- /dev/null +++ b/Tools/Controller/internal/discovery/mndp.go @@ -0,0 +1,149 @@ +package discovery + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "net" + "strconv" + "strings" + "sync/atomic" + "time" + "unicode/utf8" +) + +const MNDPPort = 5678 +const MNDPProductTLV = 0xff01 +const MNDPAPIPortTLV = 0xff02 + +var mndpSequence atomic.Uint32 + +// Standard MNDP has no application API port. The two Digitalogic extension +// TLVs identify a service/port, never credentials or permission. Generic RouterOS +// neighbours without these extensions are not invented as HTTP port 80 peers. +func EncodeMNDP(name, product, identity string, port int, seq uint16) ([]byte, error) { + if len(name) == 0 || len(name) > 96 || !utf8.ValidString(name) || strings.IndexFunc(name, func(r rune) bool { return r < 32 || r == 127 }) >= 0 || len(identity) > 128 || port < 1 || port > 65535 || !knownProduct(product) { + return nil, errors.New("invalid MNDP identity") + } + b := []byte{0, 0, byte(seq >> 8), byte(seq)} + add := func(kind uint16, value []byte) { + b = binary.BigEndian.AppendUint16(b, kind) + b = binary.BigEndian.AppendUint16(b, uint16(len(value))) + b = append(b, value...) + } + // A locally administered application-scoped identity, not a fabricated + // physical NIC or board MAC. Software-ID/interface describe that scope. + hash := sha256.Sum256([]byte(product + ":" + identity)) + mac := append([]byte(nil), hash[:6]...) + mac[0] = (mac[0] | 2) & 0xfe + add(1, mac) + add(5, []byte(name)) + add(8, []byte("Digitalogic")) + add(11, []byte(identity)) + add(12, []byte("Application endpoint")) + add(16, []byte("application")) + add(MNDPProductTLV, []byte(product)) + add(MNDPAPIPortTLV, []byte{byte(port >> 8), byte(port)}) + return b, nil +} +func knownProduct(product string) bool { + return product == "pccontroller" || product == "pealayer" || product == "controller-mobile" +} +func ParseMNDP(b []byte, source net.IP) (Instance, error) { + bad := func() (Instance, error) { return Instance{}, errors.New("invalid or unsupported MNDP packet") } + if len(b) < 14 || len(b) > 2048 || b[0] != 0 || b[1] != 0 || source == nil || source.To4() == nil || source.IsUnspecified() || source.IsMulticast() { + return bad() + } + fields := map[uint16][]byte{} + for i := 4; i < len(b); { + if i+4 > len(b) { + return bad() + } + kind, n := binary.BigEndian.Uint16(b[i:i+2]), int(binary.BigEndian.Uint16(b[i+2:i+4])) + i += 4 + if i+n > len(b) || fields[kind] != nil { + return bad() + } + fields[kind] = b[i : i+n] + i += n + } + name, product := string(fields[5]), string(fields[MNDPProductTLV]) + portBytes := fields[MNDPAPIPortTLV] + if len(fields[1]) != 6 || len(name) == 0 || len(name) > 96 || !utf8.ValidString(name) || strings.IndexFunc(name, func(r rune) bool { return r < 32 || r == 127 }) >= 0 || !knownProduct(product) || len(portBytes) != 2 { + return bad() + } + port := int(binary.BigEndian.Uint16(portBytes)) + if port == 0 { + return bad() + } + return Instance{Protocol: "mndp", Product: product, Name: name, Host: source.String(), Port: port, Addresses: []string{source.String()}, TXT: []string{"product=" + product, "api_port=" + strconv.Itoa(port)}, USN: string(fields[11]), SeenAt: time.Now()}, nil +} +func runMNDPAdvertiser(ctx context.Context, a *Advertiser) { + c := a.mndpConn + defer c.Close() + go func() { <-ctx.Done(); c.Close() }() + last := time.Time{} + replies := map[string]time.Time{} + b := make([]byte, 2049) + announce := func(to *net.UDPAddr) { + b, err := EncodeMNDP(a.name, "pccontroller", ssdpUSNWithText(a.name, a.port, a.text()), a.port, uint16(mndpSequence.Add(1))) + if err == nil { + c.WriteToUDP(b, to) + } + } + for ctx.Err() == nil { + if time.Since(last) >= 30*time.Second { + last = time.Now() + announce(&net.UDPAddr{IP: net.IPv4bcast, Port: MNDPPort}) + announce(&net.UDPAddr{IP: net.IPv4(224, 0, 0, 88), Port: MNDPPort}) + } + c.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) + n, from, err := c.ReadFromUDP(b) + if err == nil && n == 4 && string(b[:4]) == "\x00\x00\x00\x00" { + for ip, at := range replies { + if time.Since(at) >= time.Second { + delete(replies, ip) + } + } + ip := from.IP.String() + if _, exists := replies[ip]; exists || len(replies) >= 128 { + continue + } + replies[ip] = time.Now() + announce(from) + } else if err != nil { + if e, ok := err.(net.Error); !ok || !e.Timeout() { + return + } + } + } +} +func discoverMNDP(ctx context.Context, add func(Instance)) error { + group, _ := net.ResolveUDPAddr("udp4", "224.0.0.88:5678") + c, err := net.ListenMulticastUDP("udp4", nil, group) + if err != nil { + return err + } + defer c.Close() + go func() { <-ctx.Done(); c.Close() }() + c.WriteToUDP([]byte{0, 0, 0, 0}, &net.UDPAddr{IP: net.IPv4bcast, Port: MNDPPort}) + b := make([]byte, 2049) + for ctx.Err() == nil { + c.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) + n, from, err := c.ReadFromUDP(b) + if err != nil { + if e, ok := err.(net.Error); ok && e.Timeout() { + continue + } + if ctx.Err() != nil { + return nil + } + return err + } + if peer, err := ParseMNDP(b[:n], from.IP); err == nil { + add(peer) + } + } + return nil +} diff --git a/Tools/Controller/internal/discovery/public.go b/Tools/Controller/internal/discovery/public.go index 70a328b7d..d2073490f 100644 --- a/Tools/Controller/internal/discovery/public.go +++ b/Tools/Controller/internal/discovery/public.go @@ -269,6 +269,34 @@ func trustedDiscoveryURL(candidate string, instance Instance) bool { } func enrichInstance(ctx context.Context, instance Instance) Instance { + if instance.Product != "" && instance.Product != "pccontroller" { + for _, host := range append(append([]string(nil), instance.Addresses...), instance.Host) { + u := "http://" + net.JoinHostPort(host, strconv.Itoa(instance.Port)) + "/healthz" + requestCtx, cancel := context.WithTimeout(ctx, 650*time.Millisecond) + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, u, nil) + if err != nil { + cancel() + continue + } + client := &http.Client{Transport: publicHTTPTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + response, err := client.Do(request) + if err == nil { + var health struct { + Service string `json:"service"` + Status string `json:"status"` + } + err = json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&health) + response.Body.Close() + if err == nil && response.StatusCode == 200 && health.Service == instance.Product && health.Status == "ok" { + instance.Verified = true + cancel() + return instance + } + } + cancel() + } + return instance + } client := &http.Client{ Transport: publicHTTPTransport, CheckRedirect: func(request *http.Request, _ []*http.Request) error { @@ -299,6 +327,8 @@ func enrichInstance(ctx context.Context, instance Instance) Instance { } pinPublicInfoEndpoints(&info, instance) instance.Public = &info + instance.Product = "pccontroller" + instance.Verified = true instance.PublicURL = candidate if info.InstanceName != "" { instance.Name = info.InstanceName @@ -452,6 +482,10 @@ func mergeInstances(values []Instance) []Instance { continue } current := result[match] + current.Verified = current.Verified || value.Verified + if current.Product == "" { + current.Product = value.Product + } current.Protocols = uniqueStrings(append(current.Protocols, value.Protocol)) current.Sources = append(current.Sources, Source{Protocol: value.Protocol, Host: value.Host, Port: value.Port, Location: value.Location, SeenAt: value.SeenAt}) current.Addresses = uniqueStrings(append(current.Addresses, value.Addresses...)) @@ -480,6 +514,9 @@ func mergeInstances(values []Instance) []Instance { } func sameInstance(left, right Instance) bool { + if left.Product != "" && right.Product != "" && left.Product != right.Product { + return false + } if left.Public != nil && right.Public != nil && left.Public.InstanceID != "" && right.Public.InstanceID != "" { return strings.EqualFold(left.Public.InstanceID, right.Public.InstanceID) } diff --git a/Tools/Controller/internal/hostbridge/manager.go b/Tools/Controller/internal/hostbridge/manager.go index 632f90f2f..9a064ef3d 100644 --- a/Tools/Controller/internal/hostbridge/manager.go +++ b/Tools/Controller/internal/hostbridge/manager.go @@ -833,7 +833,7 @@ func (manager *Manager) reconcile(config appconfig.Config) error { discoveryConfig := config.Integrations.Discovery if discoveryConfig.MDNSEnabled || discoveryConfig.DNSSDenabled || discoveryConfig.SSDPEnabled || discoveryConfig.UPnPEnabled || discoveryConfig.WSDiscoveryEnabled || discoveryConfig.BroadcastEnabled || - discoveryConfig.NetBIOSEnabled { + discoveryConfig.NetBIOSEnabled || discoveryConfig.MNDPEnabled { _, rawPort, err := net.SplitHostPort(config.IPC.Listen) if err != nil { return err @@ -847,6 +847,7 @@ func (manager *Manager) reconcile(config appconfig.Config) error { name = config.UI.AppTitle } created, err := discovery.AdvertiseWithOptions(manager.ctx, name, port, discovery.Options{ + MNDP: discoveryConfig.MNDPEnabled, MDNS: discoveryConfig.MDNSEnabled, DNSSD: discoveryConfig.DNSSDenabled, SSDP: discoveryConfig.SSDPEnabled, UPnP: discoveryConfig.UPnPEnabled, WSDiscovery: discoveryConfig.WSDiscoveryEnabled, diff --git a/Tools/Controller/internal/ipcjson/ipc.go b/Tools/Controller/internal/ipcjson/ipc.go index b07ee7ba9..e6ba06e32 100644 --- a/Tools/Controller/internal/ipcjson/ipc.go +++ b/Tools/Controller/internal/ipcjson/ipc.go @@ -1709,6 +1709,7 @@ func (service *Service) dispatch( WSDiscovery bool `json:"ws_discovery,omitempty"` Broadcast bool `json:"broadcast,omitempty"` NetBIOS bool `json:"netbios,omitempty"` + MNDP bool `json:"mndp,omitempty"` Protocols []string `json:"protocols,omitempty"` } if err = decodeParams(request.Params, ¶ms); err == nil { @@ -1727,6 +1728,8 @@ func (service *Service) dispatch( params.Broadcast = true case "netbios", "nbns": params.NetBIOS = true + case "mndp": + params.MNDP = true default: err = fmt.Errorf("unsupported discovery protocol %q", protocol) } @@ -1735,8 +1738,9 @@ func (service *Service) dispatch( if err != nil { break } - if !params.MDNS && !params.DNSSD && !params.SSDP && !params.UPnP && !params.WSDiscovery && !params.Broadcast && !params.NetBIOS { + if !params.MDNS && !params.DNSSD && !params.SSDP && !params.UPnP && !params.WSDiscovery && !params.Broadcast && !params.NetBIOS && !params.MNDP { params.MDNS, params.DNSSD, params.SSDP, params.UPnP, params.WSDiscovery, params.Broadcast, params.NetBIOS = true, true, true, true, true, true, true + params.MNDP = true } if params.TimeoutMS == 0 { params.TimeoutMS = 1500 @@ -1750,6 +1754,7 @@ func (service *Service) dispatch( ctx, time.Duration(params.TimeoutMS)*time.Millisecond, ) result, err = discovery.DiscoverWithOptions(discoveryContext, discovery.Options{ + MNDP: params.MNDP, MDNS: params.MDNS, DNSSD: params.DNSSD, SSDP: params.SSDP, UPnP: params.UPnP, WSDiscovery: params.WSDiscovery, Broadcast: params.Broadcast, NetBIOS: params.NetBIOS, BroadcastPort: service.hostConfig().Integrations.Discovery.BroadcastPort, @@ -4012,7 +4017,8 @@ func websocketMux(serverContext context.Context, service *Service) http.Handler }) mux.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) { writeHTTPJSON(writer, http.StatusOK, map[string]any{ - "ok": true, + "ok": true, + "status": "ok", "product": "pccontroller", "service": productidentity.ServiceName( service.hostConfig().UI.AppTitle, "IPC", diff --git a/Tools/Controller/internal/ipcjson/upnp.go b/Tools/Controller/internal/ipcjson/upnp.go index fe91d9fc7..e732546cb 100644 --- a/Tools/Controller/internal/ipcjson/upnp.go +++ b/Tools/Controller/internal/ipcjson/upnp.go @@ -225,6 +225,10 @@ func publicInfo(service *Service, request *http.Request) discovery.PublicInfo { } func enabledDiscoveryProtocols(value appconfig.Discovery) []string { + var mndp = []string{} + if value.MNDPEnabled { + mndp = append(mndp, "mndp") + } result := make([]string, 0, 6) if value.MDNSEnabled || value.DNSSDenabled { result = append(result, "dns-sd") @@ -244,7 +248,7 @@ func enabledDiscoveryProtocols(value appconfig.Discovery) []string { if value.NetBIOSEnabled { result = append(result, "netbios") } - return result + return append(result, mndp...) } func resolveSOAPAction(header string, body []byte) (string, bool) { diff --git a/Tools/Controller/web/src/advanced-workbench.tsx b/Tools/Controller/web/src/advanced-workbench.tsx index e66bb10c7..7be5ee081 100644 --- a/Tools/Controller/web/src/advanced-workbench.tsx +++ b/Tools/Controller/web/src/advanced-workbench.tsx @@ -112,6 +112,7 @@ interface DiscoveryConfig { ws_discovery_enabled: boolean broadcast_enabled: boolean netbios_enabled: boolean + mndp_enabled: boolean broadcast_port: number instance_name: string } @@ -273,6 +274,7 @@ export function AdvancedWorkbench({ const [discoverWS, setDiscoverWS] = useState(true) const [discoverBroadcast, setDiscoverBroadcast] = useState(true) const [discoverNetBIOS, setDiscoverNetBIOS] = useState(true) + const [discoverMNDP, setDiscoverMNDP] = useState(true) const [discoverTimeout, setDiscoverTimeout] = useState(1500) const [discoveryInstanceName, setDiscoveryInstanceName] = useState('') const [discoveryBroadcastPort, setDiscoveryBroadcastPort] = useState(37889) @@ -349,6 +351,7 @@ export function AdvancedWorkbench({ setDiscoverWS(value.ws_discovery_enabled) setDiscoverBroadcast(value.broadcast_enabled) setDiscoverNetBIOS(value.netbios_enabled) + setDiscoverMNDP(value.mndp_enabled ?? false) setDiscoveryBroadcastPort(value.broadcast_port || 37889) setDiscoveryInstanceName(value.instance_name || '') }).catch(() => { @@ -462,6 +465,7 @@ export function AdvancedWorkbench({ ws_discovery: discoverWS, broadcast: discoverBroadcast, netbios: discoverNetBIOS, + mndp: discoverMNDP, }) setNetworkDevices(value) setServiceOutput(JSON.stringify(value, null, 2)) @@ -483,6 +487,7 @@ export function AdvancedWorkbench({ ws_discovery_enabled: discoverWS, broadcast_enabled: discoverBroadcast, netbios_enabled: discoverNetBIOS, + mndp_enabled: discoverMNDP, broadcast_port: discoveryBroadcastPort, instance_name: discoveryInstanceName.trim(), }) @@ -929,6 +934,7 @@ export function AdvancedWorkbench({ +
@@ -938,7 +944,7 @@ export function AdvancedWorkbench({

{copy('Advertisement is enabled by default and publishes only bounded public identity, health and telemetry. Remote commands still require an authenticated, remotely enabled IPC policy.', 'معرفی شبکه به‌طور پیش‌فرض فعال است و فقط هویت، سلامت و تله‌متری عمومی محدود را منتشر می‌کند. فرمان‌های راه‌دور همچنان به IPC فعال و احراز هویت‌شده نیاز دارند.')}

- + {networkDevices.length > 0 &&
{networkDevices.map((device) => { const info = device.public diff --git a/mobile/README.md b/mobile/README.md index a0ac76701..83fadce6d 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -101,6 +101,52 @@ installation/launch of this separate app, not destructive physical board tests. ## Provenance and remaining acceptance +### Unified workspace, discovery and self-package RPC + +The earlier basic Flutter design is rejected. `AGENTS.md` requires one contextual +activity workspace, not product tabs; no unavailable-only cards or dormant peer +token fields. The optional protected local automation API still keeps its actual +security boundary. Android preview delivery is not design or physical acceptance. + +Discovery browses `_pccontroller._tcp`, `_pealayer._tcp` and `_digitalogic._tcp` +using Android DNS-SD/mDNS, plus SSDP and MNDP. Each candidate is pinned to its +responder's real origin and verified by a bounded read-only health probe with no +proxy, redirects or credentials. Existing PCController health responses can be +verified against their own native public directory. Duplicate endpoints collapse; +this app's own sharing endpoint is excluded. Automatic connection applies only +to a unique verified vacant role under saved intent; explicit Disconnect and +live selections win. Actual network changes are coalesced, not probed per frame. + +Standard MNDP has no HTTP port. Digitalogic application advertisements add +big-endian TLVs `0xff01` (product) and `0xff02` (two-byte API port). An identity-only +RouterOS neighbour is not an invented controller on port 80. The MAC field is +explicitly an application-scoped locally administered identity, not a board/NIC +MAC. Generic advertisements never authorize hardware actions. + +`app.package.info/read` export only the installed single APK. The same protected +local HTTP/WS/TCP/Android Intent RPC offers `app.upgrade.begin/write/finish/status/ +abort/install/permission/pull/push`. Chunks are at most 16 KiB, request/response +JSON at most 32 KiB, one incoming transfer at most 128 MiB. Offsets are exact; +lost mutation acknowledgements are not blindly retried. Storage is checked +before 0% or staging creation. Digest, self-package, signer, newer version, +minimum SDK and CPU compatibility are verified before Android installation. + +Opt-in foreground peer sharing uses port 18992 and the same package RPC. Public +read-only export reveals no authoring, credentials or board/player proxy. A push +requires an expiring receiver approval dialog and a short-lived capability pinned +to one transfer/digest; the remote sender cannot invoke Install. Pull also verifies +the source manifest. Android installation permission and consent remain explicit; +`awaiting_android`/pending consent is not an installed receipt. There is no root, +shell installer, uninstall, silent update or cross-app APK import. Staging is in +the app's bounded private cache; only this module's own expired files are removed. +Split installs cannot currently be exported as one APK. + +Source gates: focused Dart analysis, Android Kotlin compilation, Go build/vet, +PCController Web type-check and Pealayer Cargo check passed. All-four-protocol LAN +round trips, mutual device APK transfer/install, locked-device UI and hardware +acceptance remain separate. Full CI/test-suite waits are not required for this +iteration; retain those acceptance gaps rather than claiming production-ready. + See [provenance](PROVENANCE.md) and [acceptance](ACCEPTANCE.md). The reference Pakar screens are retained in source/tests but are not the shipped entrypoint. Their ESP protocol is not an alias for PCController. Native offline authoring diff --git a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/DiscoveryChannel.kt b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/DiscoveryChannel.kt index f80155fe2..e0cdcd379 100644 --- a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/DiscoveryChannel.kt +++ b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/DiscoveryChannel.kt @@ -3,6 +3,10 @@ package ir.digitalogic.pccontroller.mobile import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkRequest import android.net.wifi.WifiManager import android.os.Handler import android.os.Looper @@ -20,21 +24,70 @@ class DiscoveryChannel(context: Context, messenger: BinaryMessenger) { private var pending: MethodChannel.Result? = null private var generation = 0 private var lock: WifiManager.MulticastLock? = null + private var externalLock = false + private var registration: NsdManager.RegistrationListener? = null + private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val networks = linkedMapOf() + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private var networkChange: Runnable? = null private val listeners = mutableListOf() private val found = linkedMapOf>() private val queue = ArrayDeque() private var resolving = false init { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { main.post { + val fingerprint = properties.linkAddresses.map { it.toString() }.sorted().joinToString(",") + if (networks[network] != fingerprint) { networks[network] = fingerprint; networkChanged() } + } } + override fun onLost(network: Network) { main.post { if (networks.remove(network) != null) networkChanged() } } + } + try { connectivity.registerNetworkCallback(NetworkRequest.Builder().build(), callback); networkCallback = callback } + catch (_: Exception) { /* Missing permission does not invent reachability. */ } channel.setMethodCallHandler { call, result -> - if (call.method != "scan") { result.notImplemented() } + if (call.method == "acquire") { + try { acquireLock(); externalLock = true; result.success(null) } + catch (_: Exception) { result.error("multicast_lock", "Wi-Fi multicast lock failed", null) } + } + else if (call.method == "advertise") { + val port = call.argument("port") ?: 0 + if (port !in 1024..65535 || registration != null) { result.error("invalid_advertisement", "Invalid port or already advertised", null) } + else { + acquireLock() + val service = NsdServiceInfo().apply { + serviceName = "Controller · ${android.os.Build.MODEL.take(40)}" + serviceType = "_digitalogic._tcp." + setPort(port); setAttribute("product", "controller-mobile") + setAttribute("health", "/healthz"); setAttribute("rpc", "/rpc") + } + val listener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(info: NsdServiceInfo) { main.post { + if (registration === this) result.success(mapOf("name" to info.serviceName, "port" to info.port)) + } } + override fun onRegistrationFailed(info: NsdServiceInfo, code: Int) { main.post { + if (registration === this) { registration = null; result.error("registration_failed", "DNS-SD registration failed ($code)", null) } + } } + override fun onServiceUnregistered(info: NsdServiceInfo) {} + override fun onUnregistrationFailed(info: NsdServiceInfo, code: Int) {} + } + registration = listener + try { manager.registerService(service, NsdManager.PROTOCOL_DNS_SD, listener) } + catch (_: Exception) { registration = null; result.error("registration_failed", "DNS-SD registration failed", null) } + } + } + else if (call.method == "unadvertise") { unadvertise(); result.success(null) } + else if (call.method == "release") { + externalLock = false; if (pending == null && registration == null) releaseLock(); result.success(null) + } + else if (call.method != "scan") { result.notImplemented() } else if (pending != null) { result.error("busy", "Discovery already running", null) } else { pending = result val epoch = ++generation found.clear(); queue.clear(); resolving = false try { - lock = wifi.createMulticastLock("pakar-discovery").apply { setReferenceCounted(false); acquire() } - for (type in listOf("_pakarcontroller._tcp.", "_http._tcp.", "_pccontroller._tcp.")) { + acquireLock() + for (type in listOf("_pakarcontroller._tcp.", "_http._tcp.", "_pccontroller._tcp.", "_pealayer._tcp.", "_digitalogic._tcp.")) { val listener = object: NsdManager.DiscoveryListener { override fun onDiscoveryStarted(s: String) {} override fun onDiscoveryStopped(s: String) {} @@ -67,7 +120,12 @@ class DiscoveryChannel(context: Context, messenger: BinaryMessenger) { if (address != null && s.port in 1..65535 && !address.contains('%')) { val host = if (address.contains(':')) "[$address]" else address val endpoint = "http://$host:${s.port}" - found[endpoint] = mapOf("name" to s.serviceName.take(96), "endpoint" to endpoint) + val product = when { + s.serviceType.startsWith("_pccontroller.") -> "pccontroller" + s.serviceType.startsWith("_pealayer.") -> "pealayer" + else -> s.attributes["product"]?.toString(Charsets.UTF_8)?.take(48) ?: "" + } + found[endpoint] = mapOf("name" to s.serviceName.take(96), "endpoint" to endpoint, "product" to product) } resolving = false; resolve(epoch) } } @@ -79,8 +137,26 @@ class DiscoveryChannel(context: Context, messenger: BinaryMessenger) { pending = null; ++generation for (listener in listeners.toList()) { try { manager.stopServiceDiscovery(listener) } catch (_: Exception) {} } listeners.clear(); queue.clear(); resolving = false - lock?.let { if (it.isHeld) it.release() }; lock = null + if (!externalLock && registration == null) releaseLock() if (error == null) result.success(found.values.toList()) else result.error("discovery_failed", error, null) } - fun close() { finish("Discovery closed"); channel.setMethodCallHandler(null) } + private fun acquireLock() { + if (lock?.isHeld == true) return + lock = wifi.createMulticastLock("digitalogic-discovery").apply { setReferenceCounted(false); acquire() } + } + private fun releaseLock() { lock?.let { if (it.isHeld) it.release() }; lock = null } + private fun networkChanged() { + networkChange?.let { main.removeCallbacks(it) } + val action = Runnable { channel.invokeMethod("networkChanged", mapOf("networks" to networks.size)) } + networkChange = action; main.postDelayed(action, 750) + } + private fun unadvertise() { + registration?.let { try { manager.unregisterService(it) } catch (_: Exception) {} }; registration = null + if (!externalLock && pending == null) releaseLock() + } + fun close() { + networkCallback?.let { try { connectivity.unregisterNetworkCallback(it) } catch (_: Exception) {} }; networkCallback = null + networkChange?.let { main.removeCallbacks(it) }; networkChange = null + externalLock = false; finish("Discovery closed"); unadvertise(); releaseLock(); channel.setMethodCallHandler(null) + } } diff --git a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt new file mode 100644 index 000000000..01c2cfdbe --- /dev/null +++ b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt @@ -0,0 +1,226 @@ +package ir.digitalogic.pccontroller.mobile + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.net.Uri +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.MethodChannel +import java.io.File +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile + +/** Self-package only, bounded chunks, same signer, newer version, OS consent. + * No shell/root installation, uninstall, third-party APKs or arbitrary paths. + */ +class UpgradeChannel(private val activity: Activity, messenger: BinaryMessenger) { + companion object { var liveChannel: MethodChannel? = null } + private val channel = MethodChannel(messenger, "ir.digitalogic.pccontroller/upgrade") + private val io = Executors.newSingleThreadExecutor() + private val waiting = AtomicInteger() + private val root = File(activity.cacheDir, "peer-upgrades") + @Volatile private var closed = false + private var transfer: Transfer? = null + private var cachedExport: Pair>? = null + private val maximum = 128L * 1024 * 1024 + private val chunkSize = 16384 + private data class Transfer(val id: String, val file: File, val size: Long, + val hash: String, var offset: Long = 0, var ready: Boolean = false, + var last: Long = System.currentTimeMillis()) + init { + liveChannel = channel + // Only this module's abandoned staging files, never installed APKs/data. + root.listFiles()?.filter { it.name.matches(Regex("[a-f0-9-]{36}\\.apk")) && + System.currentTimeMillis() - it.lastModified() > 30 * 60 * 1000 }?.forEach { it.delete() } + channel.setMethodCallHandler { call, result -> + if (waiting.incrementAndGet() > 8) { + waiting.decrementAndGet(); result.error("busy", "Upgrade queue full", null) + } else io.execute { + try { + check(!closed) { "Upgrade channel closed" } + transfer?.let { if (System.currentTimeMillis() - it.last > 10 * 60 * 1000) abort() } + val args = call.arguments as? Map<*, *> ?: emptyMap() + val value = when (call.method) { + "info" -> exportInfo() + "read" -> read(args) + "begin" -> begin(args) + "write" -> write(args) + "finish" -> finish(args) + "abort" -> { requireTransfer(args); abort(); mapOf("aborted" to true) } + "status" -> status() + "install" -> install(args) + "permission" -> { + activity.runOnUiThread { if (!closed && Build.VERSION.SDK_INT >= 26) { + activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${activity.packageName}"))) + } } + status() + mapOf("requested" to true) + } + else -> error("Unknown upgrade method") + } + activity.runOnUiThread { if (!closed) result.success(value) } + } catch (error: Exception) { + activity.runOnUiThread { if (!closed) result.error("upgrade_rejected", error.message ?: "Upgrade rejected", null) } + } finally { waiting.decrementAndGet() } + } + } + } + @Suppress("DEPRECATION") + private fun info(path: String? = null): PackageInfo { + val flags = if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES + return if (path == null) activity.packageManager.getPackageInfo(activity.packageName, flags) + else activity.packageManager.getPackageArchiveInfo(path, flags) ?: error("Invalid APK") + } + @Suppress("DEPRECATION") + private fun signers(value: PackageInfo): List { + val signatures = if (Build.VERSION.SDK_INT >= 28) value.signingInfo?.apkContentsSigners else value.signatures + return (signatures ?: emptyArray()).map { hash(it.toByteArray()) }.sorted().also { + require(it.isNotEmpty()) { "Package has no verified signer" } + } + } + @Suppress("DEPRECATION") + private fun version(value: PackageInfo): Long = if (Build.VERSION.SDK_INT >= 28) value.longVersionCode else value.versionCode.toLong() + private fun hash(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + private fun digest(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> val buffer = ByteArray(65536); while (true) { + val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) + } } + return digest.digest().joinToString("") { "%02x".format(it) } + } + private fun installedApk(): File { + require(activity.applicationInfo.splitSourceDirs.isNullOrEmpty()) { "Split installs cannot be exported as one APK" } + return File(activity.applicationInfo.sourceDir).also { require(it.length() in 1..maximum) { "APK exceeds transfer bound" } } + } + private fun exportInfo(): Map { + val file = installedApk(); val key = "${file.path}:${file.length()}:${file.lastModified()}" + cachedExport?.let { if (it.first == key) return it.second } + val current = info() + return mapOf("package" to activity.packageName, "version_code" to version(current), + "version_name" to (current.versionName ?: ""), "bytes" to file.length(), + "sha256" to digest(file), "signers" to signers(current), "chunk_bytes" to chunkSize, + "abis" to Build.SUPPORTED_ABIS.toList(), "sdk" to Build.VERSION.SDK_INT).also { cachedExport = Pair(key, it) } + } + private fun read(args: Map<*, *>): Map { + val file = installedApk(); val offset = (args["offset"] as? Number)?.toLong() ?: -1 + val length = (args["length"] as? Number)?.toInt() ?: chunkSize + require(offset in 0..file.length() && length in 1..chunkSize) { "Invalid APK read range" } + val bytes = ByteArray(minOf(length.toLong(), file.length() - offset).toInt()) + RandomAccessFile(file, "r").use { it.seek(offset); it.readFully(bytes) } + return mapOf("offset" to offset, "bytes" to bytes, "eof" to (offset + bytes.size == file.length())) + } + private fun begin(args: Map<*, *>): Map { + require(transfer == null) { "Finish or abort the existing transfer" } + val size = (args["bytes"] as? Number)?.toLong() ?: -1 + val hash = args["sha256"] as? String ?: "" + require(size in 1..maximum && hash.matches(Regex("[a-f0-9]{64}"))) { "Invalid APK manifest" } + // Before 0% or file creation; installation needs an additional OS copy. + require(activity.cacheDir.usableSpace >= size * 2 + 64 * 1024 * 1024) { "Insufficient space for APK and Android installation" } + require(root.exists() || root.mkdirs()) { "Cannot create private staging" } + val id = UUID.randomUUID().toString(); val file = File(root, "$id.apk") + require(file.createNewFile()) { "Cannot create private transfer" } + transfer = Transfer(id, file, size, hash) + return status() + } + private fun requireTransfer(args: Map<*, *>): Transfer = (transfer ?: error("No staged APK")).also { + require(args["transfer_id"] == it.id) { "Transfer identity changed" }; it.last = System.currentTimeMillis() + } + private fun write(args: Map<*, *>): Map { + val current = requireTransfer(args) + val offset = (args["offset"] as? Number)?.toLong() ?: -1 + val bytes = args["bytes"] as? ByteArray ?: error("Missing APK bytes") + require(!current.ready && offset == current.offset && bytes.size in 1..chunkSize && + offset + bytes.size <= current.size) { "Invalid or replayed APK chunk; query status, do not retry blindly" } + RandomAccessFile(current.file, "rw").use { it.seek(offset); it.write(bytes) } + current.offset += bytes.size + return status() + } + private fun finish(args: Map<*, *>): Map { + val current = requireTransfer(args) + require(current.offset == current.size && current.file.length() == current.size) { "Incomplete APK" } + try { + require(digest(current.file) == current.hash) { "APK digest mismatch" } + val archive = info(current.file.path); val installed = info() + require(archive.packageName == activity.packageName) { "Wrong application package" } + require(signers(archive) == signers(installed)) { "APK signer differs from installed app" } + require(version(archive) > version(installed)) { "APK is not newer than installed app" } + if (Build.VERSION.SDK_INT >= 24) require((archive.applicationInfo?.minSdkVersion ?: 0) <= Build.VERSION.SDK_INT) { "APK requires a newer Android version" } + ZipFile(current.file).use { zip -> + val abis = mutableSetOf(); var expanded = 0L; var count = 0 + for (entry in zip.entries().asSequence()) { + require(++count <= 20000 && entry.size >= 0) { "Invalid APK archive" } + expanded += entry.size; require(expanded <= 512L * 1024 * 1024) { "APK expansion too large" } + if (entry.name.startsWith("lib/") && entry.name.endsWith(".so")) abis.add(entry.name.split('/').getOrElse(1) { "" }) + } + require(abis.isEmpty() || Build.SUPPORTED_ABIS.any { it in abis }) { "APK does not support this CPU" } + } + current.ready = true + return status() + mapOf("package" to archive.packageName, "version_code" to version(archive), "version_name" to (archive.versionName ?: "")) + } catch (error: Exception) { abort(); throw error } + } + private fun install(args: Map<*, *>): Map { + val current = requireTransfer(args); require(current.ready) { "APK has not been verified" } + if (Build.VERSION.SDK_INT >= 26 && !activity.packageManager.canRequestPackageInstalls()) + return status() + mapOf("state" to "permission_required") + val installer = activity.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(activity.packageName); setSize(current.size) + if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED) + } + val sessionId = installer.createSession(params) + try { + installer.openSession(sessionId).use { session -> + session.openWrite("base.apk", 0, current.size).use { output -> + current.file.inputStream().use { it.copyTo(output, 65536) }; session.fsync(output) + } + val intent = Intent(activity, UpgradeResultReceiver::class.java).putExtra("session_id", sessionId) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0) + val callback = PendingIntent.getBroadcast(activity, sessionId, intent, flags) + session.commit(callback.intentSender) + } + } catch (error: Exception) { installer.abandonSession(sessionId); throw error } + abort() // Android now owns its one required installation copy. + return mapOf("state" to "awaiting_android", "session_id" to sessionId) + } + private fun status(): Map { + val current = transfer + return if (current == null) mapOf("state" to "idle", "install_status" to (activity.getSharedPreferences("peer-upgrades", Context.MODE_PRIVATE).getString("receipt", "") ?: "")) + else mapOf("state" to if (current.ready) "verified" else "receiving", "transfer_id" to current.id, + "bytes" to current.size, "received" to current.offset, "sha256" to current.hash, + "install_permission" to (Build.VERSION.SDK_INT < 26 || activity.packageManager.canRequestPackageInstalls())) + } + private fun abort() { transfer?.file?.delete(); transfer = null } + fun close() { closed = true; if (liveChannel === channel) liveChannel = null; channel.setMethodCallHandler(null); io.execute { abort() }; io.shutdown() } +} + +/** The platform verifies and asks the human; a committed session is not success. */ +class UpgradeResultReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val receipt = org.json.JSONObject().put("session_id", intent.getIntExtra("session_id", -1)) + .put("status", status).put("message", intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "") + context.getSharedPreferences("peer-upgrades", Context.MODE_PRIVATE).edit().putString("receipt", receipt.toString()).apply() + Handler(Looper.getMainLooper()).post { + UpgradeChannel.liveChannel?.invokeMethod("installStatus", mapOf("session_id" to intent.getIntExtra("session_id", -1), "status" to status, + "message" to (intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: ""))) + } + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @Suppress("DEPRECATION") val confirmation = intent.getParcelableExtra(Intent.EXTRA_INTENT) + try { confirmation?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)?.let { context.startActivity(it) } } + catch (_: Exception) { /* Receipt remains pending, never claim installed. */ } + } + } +} diff --git a/mobile/lib/discovery.dart b/mobile/lib/discovery.dart index c71fe6630..6045e005f 100644 --- a/mobile/lib/discovery.dart +++ b/mobile/lib/discovery.dart @@ -6,52 +6,104 @@ import 'package:flutter/services.dart'; import 'api.dart'; +const discoveryProducts = {'pccontroller', 'pealayer', 'controller-mobile'}; + class DiscoveredDevice { - const DiscoveredDevice(this.name, this.endpoint, this.protocol); - final String name, endpoint, protocol; + const DiscoveredDevice( + this.name, + this.endpoint, + this.protocol, { + this.product = '', + this.verified = false, + }); + final String name, endpoint, protocol, product; + final bool verified; } -/// Metadata only, adapted from PCController discovery.go and PU MNDP TLVs. -/// No credentials are sent by scanning, and advertisements never authorize. +/// Hints, never authorization or evidence of board presence. Run on intent or +/// network change, not continuous polling; no credentials or persistence. class DeviceDiscovery { static const channel = MethodChannel('ir.digitalogic.pccontroller/discovery'); + static final localEndpoints = {}; final errors = {}; + bool _scanning = false; Future> scan() async { + if (_scanning) throw StateError('Discovery already running'); + _scanning = true; errors.clear(); final found = []; - await Future.wait([ - _guard('mDNS / DNS-SD', () async { - final raw = await channel.invokeListMethod('scan'); - for (final v in raw ?? []) { - if (v is! Map || v['endpoint'] is! String) continue; - try { - found.add( - DiscoveredDevice( - '${v['name']}', - PakarApi.normalizeEndpoint(v['endpoint']), - 'mDNS / DNS-SD', - ), - ); - } on FormatException { - /* Reject invalid advertised origins. */ + try { + try { + await channel.invokeMethod('acquire'); + } on MissingPluginException { + /* Desktop sockets need no Android lock. */ + } + await Future.wait([ + _guard('mDNS / DNS-SD', () async { + final raw = await channel.invokeListMethod('scan'); + for (final v in raw ?? []) { + if (v is! Map || v['endpoint'] is! String || found.length >= 128) { + continue; + } + try { + found.add( + DiscoveredDevice( + '${v['name']}', + PakarApi.normalizeEndpoint(v['endpoint']), + 'mDNS / DNS-SD', + product: '${v['product'] ?? ''}', + ), + ); + } on FormatException { + /* Invalid advertised origin. */ + } } - } - }), - _guard('SSDP', () => _udp(found, false)), - _guard('MNDP', () => _udp(found, true)), - ]); - final unique = {}; - for (final d in found.take(128)) { - unique['${d.endpoint}|${d.protocol}'] = d; + }), + _guard('SSDP', () => _udp(found, false)), + _guard('MNDP', () => _udp(found, true)), + ]); + final candidates = {}; + for (final d in found.take(128)) { + if (localEndpoints.contains(d.endpoint)) continue; + final old = candidates[d.endpoint]; + candidates[d.endpoint] = DiscoveredDevice( + d.name, + d.endpoint, + {...?old?.protocol.split(', '), d.protocol}.join(', '), + product: d.product.isEmpty ? old?.product ?? '' : d.product, + ); + } + final queue = candidates.values.toList(), verified = []; + var next = 0; + final deadline = DateTime.now().add(const Duration(seconds: 12)); + await Future.wait( + List.generate(4, (_) async { + while (next < queue.length && DateTime.now().isBefore(deadline)) { + final peer = await verifyDiscoveredDevice(queue[next++]); + if (peer != null) verified.add(peer); + } + }), + ); + return verified..sort((a, b) => a.name.compareTo(b.name)); + } finally { + try { + await channel.invokeMethod('release'); + } on Object { + /* Cleanup. */ + } + _scanning = false; } - return unique.values.toList()..sort((a, b) => a.name.compareTo(b.name)); } Future _guard(String protocol, Future Function() work) async { try { await work(); - } on Object { - errors[protocol] = 'Unavailable on this network/platform'; + } on SocketException catch (e) { + errors[protocol] = '${e.osError?.errorCode ?? 'socket'}'; + } on PlatformException catch (e) { + errors[protocol] = e.code; + } on MissingPluginException { + errors[protocol] = 'unsupported_platform'; } } @@ -73,12 +125,13 @@ class DeviceDiscovery { final p = packet!; final d = mndp ? parseMndp(p.data, p.address.address) - : parseSsdp(p.data); + : parseSsdp(p.data, source: p.address.address); if (d != null) found.add(d); } }); if (mndp) { socket.send([0, 0, 0, 0], InternetAddress('255.255.255.255'), 5678); + socket.send([0, 0, 0, 0], InternetAddress('224.0.0.88'), 5678); } else { socket.send( utf8.encode( @@ -97,7 +150,86 @@ class DeviceDiscovery { } } -DiscoveredDevice? parseSsdp(Uint8List packet) { +/// Source-pinned read-only probe. No proxy, redirects, secrets or mutations. +Future verifyDiscoveredDevice( + DiscoveredDevice candidate, +) async { + final origin = Uri.parse(candidate.endpoint); + final ip = InternetAddress.tryParse(origin.host); + if (ip == null || + ip.isMulticast || + ip.isLoopback || + ip.address == '0.0.0.0') { + return null; + } + final client = HttpClient() + ..findProxy = ((_) => 'DIRECT') + ..connectionTimeout = const Duration(milliseconds: 800); + try { + final request = await client.getUrl(origin.resolve('/healthz')); + request.followRedirects = false; + final response = await request.close().timeout(const Duration(seconds: 1)); + if (response.statusCode != 200) return null; + final bytes = []; + await for (final chunk in response.timeout(const Duration(seconds: 1))) { + if (bytes.length + chunk.length > 4096) return null; + bytes.addAll(chunk); + } + var value = jsonDecode(utf8.decode(bytes)); + // Compatibility proof for installed alpha hosts predating stable health IDs. + if (value is Map && + value['ok'] == true && + value['status'] == null && + (candidate.product.isEmpty || candidate.product == 'pccontroller')) { + final publicRequest = await client.getUrl( + origin.resolve('/upnp/public.json'), + ); + publicRequest.followRedirects = false; + final publicResponse = await publicRequest.close().timeout( + const Duration(seconds: 1), + ); + if (publicResponse.statusCode != 200) return null; + final publicBytes = []; + await for (final chunk in publicResponse.timeout( + const Duration(seconds: 1), + )) { + if (publicBytes.length + chunk.length > 16384) return null; + publicBytes.addAll(chunk); + } + final directory = jsonDecode(utf8.decode(publicBytes)); + if (directory is! Map || + directory['product'] != 'PCController' || + directory['hostname'] is! String || + directory['health'] is! Map || + directory['health']['ok'] != true) { + return null; + } + value = {'status': 'ok', 'product': 'pccontroller'}; + } + if (value is! Map || + value['status'] != 'ok' || + !discoveryProducts.contains(value['product'] ?? value['service'])) { + return null; + } + final product = (value['product'] ?? value['service']) as String; + if (candidate.product.isNotEmpty && candidate.product != product) { + return null; + } + return DiscoveredDevice( + candidate.name, + candidate.endpoint, + candidate.protocol, + product: product, + verified: true, + ); + } on Object { + return null; + } finally { + client.close(force: true); + } +} + +DiscoveredDevice? parseSsdp(Uint8List packet, {String? source}) { if (packet.length > 4096) return null; try { final lines = const LineSplitter().convert(utf8.decode(packet)); @@ -106,9 +238,9 @@ DiscoveredDevice? parseSsdp(Uint8List packet) { for (final line in lines.skip(1)) { final i = line.indexOf(':'); if (i > 0) { - headers[line.substring(0, i).trim().toLowerCase()] = line - .substring(i + 1) - .trim(); + final key = line.substring(0, i).trim().toLowerCase(); + if (headers.containsKey(key)) return null; + headers[key] = line.substring(i + 1).trim(); } } final u = Uri.parse(headers['location'] ?? ''); @@ -117,13 +249,23 @@ DiscoveredDevice? parseSsdp(Uint8List packet) { !['http', 'https'].contains(u.scheme)) { return null; } + if (source != null && InternetAddress.tryParse(u.host)?.address != source) { + return null; + } final endpoint = PakarApi.normalizeEndpoint( u.replace(path: '', query: null, fragment: null).toString(), ); + final product = + headers['x-digitalogic-product'] ?? + (headers.containsKey('x-pccontroller-name') ? 'pccontroller' : ''); return DiscoveredDevice( - headers['x-pccontroller-name'] ?? headers['usn'] ?? u.host, + headers['x-pccontroller-name'] ?? + headers['x-digitalogic-name'] ?? + headers['usn'] ?? + u.host, endpoint, 'SSDP', + product: discoveryProducts.contains(product) ? product : '', ); } on FormatException { return null; @@ -131,7 +273,19 @@ DiscoveredDevice? parseSsdp(Uint8List packet) { } DiscoveredDevice? parseMndp(Uint8List packet, String source) { - if (packet.length < 8 || packet.length > 2048) return null; + if (packet.length < 14 || + packet.length > 2048 || + packet[0] != 0 || + packet[1] != 0) { + return null; + } + final ip = InternetAddress.tryParse(source); + if (ip == null || + ip.type != InternetAddressType.IPv4 || + ip.isMulticast || + source == '0.0.0.0') { + return null; + } final data = ByteData.sublistView(packet); final fields = {}; for (var i = 4; i < packet.length;) { @@ -143,17 +297,25 @@ DiscoveredDevice? parseMndp(Uint8List packet, String source) { i += length; } try { - final name = utf8.decode(fields[5] ?? []); - if (name.isEmpty || + final name = utf8.decode(fields[5] ?? []), + product = utf8.decode(fields[0xff01] ?? []); + final portBytes = fields[0xff02]; + if (fields[1]?.length != 6 || + name.isEmpty || name.length > 96 || - RegExp(r'[\x00-\x1f\x7f]').hasMatch(name)) { + RegExp(r'[\x00-\x1f\x7f]').hasMatch(name) || + !discoveryProducts.contains(product) || + portBytes?.length != 2) { return null; } - // MNDP has no API port/auth contract: present as unverified HTTP candidate. + final port = ByteData.sublistView(portBytes!).getUint16(0); + if (port == 0) return null; + // Standard MNDP has no API port. This vendor extension never assumes 80. return DiscoveredDevice( name, - PakarApi.normalizeEndpoint('http://$source'), + PakarApi.normalizeEndpoint('http://$source:$port'), 'MNDP', + product: product, ); } on FormatException { return null; diff --git a/mobile/lib/unified/app_upgrade.dart b/mobile/lib/unified/app_upgrade.dart new file mode 100644 index 000000000..a12bb74ef --- /dev/null +++ b/mobile/lib/unified/app_upgrade.dart @@ -0,0 +1,362 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'transport.dart' show Json, object, endpoint; + +/// One self-package transfer, shared by UI and protected HTTP/WS/TCP/Intent RPC. +/// Installed APK export is read-only; receiving/installing never auto-retries. +class AppUpgrade extends ChangeNotifier { + static const channel = MethodChannel('ir.digitalogic.pccontroller/upgrade'); + static const maximum = 128 * 1024 * 1024, chunkSize = 16384; + static const methods = { + 'app.package.info', + 'app.package.read', + 'app.upgrade.begin', + 'app.upgrade.write', + 'app.upgrade.finish', + 'app.upgrade.abort', + 'app.upgrade.status', + 'app.upgrade.install', + 'app.upgrade.permission', + 'app.upgrade.pull', + 'app.upgrade.push', + }; + Json state = {'state': 'idle'}; + bool pulling = false; + Timer? _changed; + void _notify() { + if (state['state'] == 'receiving' || state['state'] == 'transmitting') { + _changed ??= Timer(const Duration(milliseconds: 80), () { + _changed = null; + notifyListeners(); + }); + } else { + _changed?.cancel(); + _changed = null; + notifyListeners(); + } + } + + AppUpgrade() { + channel.setMethodCallHandler((call) async { + if (call.method == 'installStatus') { + state = {'state': 'android_receipt', ...object(call.arguments)}; + notifyListeners(); + } + }); + } + Future rpc(String method, Json params) async { + if (!methods.contains(method)) { + throw const FormatException('Unknown package method'); + } + if (method == 'app.upgrade.pull') { + return pull('${params['endpoint'] ?? ''}'); + } + if (method == 'app.upgrade.push') { + return push('${params['endpoint'] ?? ''}'); + } + final operation = method.split('.').last; + final args = Map.of(params); + if (operation == 'write') { + final encoded = args.remove('data'); + if (encoded is! String || encoded.length > 21848) { + throw const FormatException('Invalid APK chunk'); + } + final bytes = base64Decode(encoded); + if (bytes.isEmpty || bytes.length > chunkSize) { + throw const FormatException('APK chunk exceeds 16 KiB'); + } + args['bytes'] = bytes; + } + final result = object(await channel.invokeMethod(operation, args)); + if (operation == 'status' && + result['state'] == 'idle' && + result['install_status'] is String && + (result['install_status'] as String).isNotEmpty) { + try { + final receipt = object(jsonDecode(result['install_status'])); + if (receipt['status'] is int) { + result.clear(); + result.addAll({'state': 'android_receipt', ...receipt}); + } + } on FormatException { + /* A malformed receipt is not installation proof. */ + } + } + if (operation == 'read') { + final bytes = result.remove('bytes'); + if (bytes is! Uint8List || bytes.length > chunkSize) { + throw const FormatException('Invalid APK read response'); + } + result['data'] = base64Encode(bytes); + } else if (method.startsWith('app.upgrade.')) { + state = result; + _notify(); + } + return result; + } + + Future pull(String address) async { + if (pulling || state['transfer_id'] != null) { + throw StateError('Finish the current APK transfer'); + } + final origin = peerOrigin(address); + pulling = true; + final client = HttpClient() + ..findProxy = ((_) => 'DIRECT') + ..connectionTimeout = const Duration(seconds: 2); + String? transfer; + try { + // Preflight before 0%: source identity, manifest, version, signer, space. + state = {'state': 'verifying'}; + notifyListeners(); + final health = await boundedPeerGet(client, origin.resolve('/healthz')); + if (health['status'] != 'ok' || + health['service'] != 'controller-mobile') { + throw const FormatException('Peer is not a controller app'); + } + final remote = await peerRpc(client, origin, 'app.package.info', {}); + final local = await rpc('app.package.info', {}); + final size = remote['bytes']; + if (size is! int || + size < 1 || + size > maximum || + remote['package'] != local['package'] || + remote['version_code'] is! int || + (remote['version_code'] as int) <= (local['version_code'] as int) || + jsonEncode(remote['signers']) != jsonEncode(local['signers']) || + !RegExp(r'^[a-f0-9]{64}$').hasMatch('${remote['sha256']}')) { + throw const FormatException( + 'Peer APK is not a newer compatible signed package', + ); + } + final started = await rpc('app.upgrade.begin', { + 'bytes': size, + 'sha256': remote['sha256'], + }); + transfer = started['transfer_id'] as String; + var offset = 0; + while (offset < size) { + final packet = await peerRpc(client, origin, 'app.package.read', { + 'offset': offset, + 'length': chunkSize, + }); + final encoded = packet['data']; + if (packet['offset'] != offset || + encoded is! String || + encoded.length > 21848) { + throw const FormatException('Peer APK range changed'); + } + final bytes = base64Decode(encoded); + if (bytes.isEmpty || + bytes.length > chunkSize || + offset + bytes.length > size) { + throw const FormatException('Peer APK size changed'); + } + final result = await rpc('app.upgrade.write', { + 'transfer_id': transfer, + 'offset': offset, + 'data': encoded, + }); + offset += bytes.length; + if (result['received'] != offset) { + throw const FormatException( + 'Uncertain APK chunk receipt; not replayed', + ); + } + } + final result = await rpc('app.upgrade.finish', {'transfer_id': transfer}); + transfer = null; // Keep verified staging until explicit Install or Abort. + return result; + } catch (error) { + if (transfer != null) { + try { + await rpc('app.upgrade.abort', {'transfer_id': transfer}); + } on Object { + /* Never replay installation. */ + } + } + state = {'state': 'failed', 'error': '$error'}; + rethrow; + } finally { + pulling = false; + client.close(force: true); + notifyListeners(); + } + } + + /// Peer approval stages bytes only; sender cannot invoke the installer. + Future push(String address) async { + if (pulling || state['transfer_id'] != null) { + throw StateError('Finish the current APK transfer'); + } + final origin = peerOrigin(address); + final client = HttpClient() + ..findProxy = ((_) => 'DIRECT') + ..connectionTimeout = const Duration(seconds: 2); + pulling = true; + String? transfer, capability; + try { + state = {'state': 'offering'}; + notifyListeners(); + final health = await boundedPeerGet(client, origin.resolve('/healthz')); + if (health['status'] != 'ok' || + health['service'] != 'controller-mobile') { + throw const FormatException('Peer is not a controller app'); + } + final manifest = await rpc('app.package.info', {}); + final receipt = await peerRpc( + client, + origin, + 'app.upgrade.offer', + manifest, + ); + transfer = receipt['transfer_id'] as String; + capability = receipt['capability'] as String; + if (capability.length < 32 || + capability.length > 128 || + receipt['bytes'] != manifest['bytes'] || + receipt['sha256'] != manifest['sha256']) { + throw const FormatException('Invalid peer transfer receipt'); + } + final size = manifest['bytes'] as int; + var offset = 0; + while (offset < size) { + final packet = await rpc('app.package.read', { + 'offset': offset, + 'length': chunkSize, + }); + final data = packet['data'] as String; + final bytes = base64Decode(data); + if (packet['offset'] != offset || + bytes.isEmpty || + bytes.length > chunkSize || + offset + bytes.length > size) { + throw const FormatException('Installed APK changed'); + } + final result = await peerRpc(client, origin, 'app.upgrade.write', { + 'transfer_id': transfer, + 'offset': offset, + 'data': data, + }, capability: capability); + offset += bytes.length; + if (result['received'] != offset) { + throw const FormatException( + 'Uncertain peer receipt; upload not replayed', + ); + } + state = { + 'state': 'transmitting', + 'bytes': size, + 'sent': offset, + 'endpoint': address, + }; + _notify(); + } + final result = await peerRpc(client, origin, 'app.upgrade.finish', { + 'transfer_id': transfer, + }, capability: capability); + transfer = null; + state = { + 'state': 'delivered', + 'endpoint': address, + 'version_code': manifest['version_code'], + }; + notifyListeners(); + return result; + } catch (error) { + if (transfer != null && capability != null) { + try { + await peerRpc(client, origin, 'app.upgrade.abort', { + 'transfer_id': transfer, + }, capability: capability); + } on Object { + /* No blind replay. */ + } + } + state = {'state': 'failed', 'error': '$error'}; + rethrow; + } finally { + pulling = false; + client.close(force: true); + notifyListeners(); + } + } + + @override + void dispose() { + _changed?.cancel(); + channel.setMethodCallHandler(null); + super.dispose(); + } +} + +Uri peerOrigin(String value) { + final origin = endpoint(value); + final ip = InternetAddress.tryParse(origin.host); + if (ip == null || + ip.type != InternetAddressType.IPv4 || + ip.isMulticast || + ip.isLoopback || + ip.address == '0.0.0.0' || + ip.address == '255.255.255.255') { + throw const FormatException( + 'Peer updates require a verified literal LAN origin', + ); + } + return origin; +} + +Future boundedPeerGet(HttpClient client, Uri uri) async { + final request = await client.getUrl(uri); + request.followRedirects = false; + final response = await request.close().timeout(const Duration(seconds: 3)); + return readPeerJson(response); +} + +Future readPeerJson(HttpClientResponse response) async { + if (response.statusCode != 200) { + throw HttpException( + 'Peer rejected package request (${response.statusCode})', + ); + } + final bytes = []; + await for (final chunk in response.timeout(const Duration(seconds: 3))) { + if (bytes.length + chunk.length > 32768) { + throw const FormatException('Peer response exceeds 32 KiB'); + } + bytes.addAll(chunk); + } + return object(jsonDecode(utf8.decode(bytes))); +} + +Future peerRpc( + HttpClient client, + Uri origin, + String method, + Json params, { + String? capability, +}) async { + final request = await client.postUrl(origin.resolve('/rpc')); + request.followRedirects = false; + request.headers.contentType = ContentType.json; + if (capability != null) { + request.headers.set('authorization', 'Bearer $capability'); + } + request.write( + jsonEncode({'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}), + ); + final result = await readPeerJson( + await request.close().timeout(const Duration(seconds: 35)), + ); + if (result['id'] != 1 || + result.containsKey('error') || + result['result'] is! Map) { + throw const FormatException('Peer rejected package RPC'); + } + return object(result['result']); +} diff --git a/mobile/lib/unified/automation.dart b/mobile/lib/unified/automation.dart index 6f4b632ad..4c6c9737d 100644 --- a/mobile/lib/unified/automation.dart +++ b/mobile/lib/unified/automation.dart @@ -125,17 +125,19 @@ class LocalAutomation { return error(id, -32602, 'Use authenticated HTTP for app capture'); } final method = request['method'] as String; - final mutating = [ - 'board.action', - 'board.pwm.set', - 'board.connect', - 'board.disconnect', - 'board.menu.press', - 'board.display.show', - 'board.buzzer.play', - 'board.mode.set', - 'player.call', - ].contains(method); + final mutating = + [ + 'board.action', + 'board.pwm.set', + 'board.connect', + 'board.disconnect', + 'board.menu.press', + 'board.display.show', + 'board.buzzer.play', + 'board.mode.set', + 'player.call', + ].contains(method) || + method.startsWith('app.upgrade.'); if (mutating && _mutating) { return error(id, -32003, 'Another automation action is pending'); } diff --git a/mobile/lib/unified/peer_upgrades.dart b/mobile/lib/unified/peer_upgrades.dart new file mode 100644 index 000000000..20412f484 --- /dev/null +++ b/mobile/lib/unified/peer_upgrades.dart @@ -0,0 +1,405 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import '../discovery.dart'; +import 'app_upgrade.dart'; +import 'automation.dart' show newAutomationToken, sameSecret; +import 'transport.dart' show Json, object; + +typedef UpgradeApproval = Future Function(Json manifest); + +/// Explicit foreground-only APK sharing. Public methods reveal only the APK, +/// not app state, authoring, credentials or board/player commands. An incoming +/// offer must be approved here; its short-lived capability pins one digest and +/// transfer. Installation still requires a separate local action + OS consent. +class PeerUpgrades { + PeerUpgrades(this.upgrades, {required this.approve}); + final AppUpgrade upgrades; + final UpgradeApproval approve; + HttpServer? _server; + final _sockets = []; + Timer? _announce; + String? _capability, _transfer; + DateTime? _expires; + bool _offering = false; + int _active = 0, _epoch = 0, _sequence = 0; + final _instance = newAutomationToken(); + late final _ssdpId = () { + final hex = sha256 + .convert(utf8.encode(_instance)) + .toString() + .substring(0, 32); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; + }(); + final errors = {}; + final _addresses = []; + final _responded = {}; + bool get running => _server != null; + int? get port => _server?.port; + Future start() async { + if (running) return; + errors.clear(); + await upgrades.rpc( + 'app.package.info', + {}, + ); // No unreachable/split APK advertisement. + final epoch = ++_epoch; + final server = await HttpServer.bind(InternetAddress.anyIPv4, 18992); + if (epoch != _epoch) { + await server.close(force: true); + return; + } + _server = server; + server.idleTimeout = const Duration(seconds: 5); + _addresses.clear(); + _addresses.addAll( + (await NetworkInterface.list(type: InternetAddressType.IPv4)) + .expand((n) => n.addresses) + .where((ip) => !ip.isLoopback), + ); + DeviceDiscovery.localEndpoints.addAll( + _addresses.map((ip) => 'http://${ip.address}:${server.port}'), + ); + server.listen( + (request) => unawaited( + _request(request, epoch).catchError((_) async { + request.response.statusCode = 400; + await request.response.close(); + }), + ), + ); + // Advertisement failures never turn this API into a board or success proof. + try { + await DeviceDiscovery.channel.invokeMethod('advertise', { + 'port': server.port, + }); + } on Object catch (error) { + errors['dns-sd'] = '$error'; + } + for (final mndp in [false, true]) { + try { + await _udp(mndp, epoch); + } on SocketException catch (error) { + errors[mndp ? 'mndp' : 'ssdp'] = + '${error.osError?.errorCode ?? 'socket'}'; + } + } + _announce = Timer.periodic(const Duration(seconds: 30), (_) => _beacon()); + _beacon(); + } + + Future _request(HttpRequest request, int epoch) async { + final response = request.response; + response.headers.contentType = ContentType.json; + response.headers.set('Cache-Control', 'no-store'); + if (!running || epoch != _epoch || _active >= 4) { + response.statusCode = 429; + await response.close(); + return; + } + if (request.method == 'GET' && request.uri.path == '/healthz') { + response.write( + jsonEncode({'status': 'ok', 'service': 'controller-mobile'}), + ); + await response.close(); + return; + } + if (request.method == 'GET' && request.uri.path == '/api/discovery') { + response.write( + jsonEncode({ + 'product': 'controller-mobile', + 'rpc': '/rpc', + 'package_sharing': true, + 'adapter_errors': errors, + }), + ); + await response.close(); + return; + } + if (request.method != 'POST' || request.uri.path != '/rpc') { + response.statusCode = 404; + await response.close(); + return; + } + _active++; + dynamic id; + try { + final bytes = []; + await for (final chunk in request.timeout(const Duration(seconds: 3))) { + if (bytes.length + chunk.length > 32768) { + throw const FormatException('RPC exceeds 32 KiB'); + } + bytes.addAll(chunk); + } + final rpc = object(jsonDecode(utf8.decode(bytes))); + id = rpc['id']; + if (rpc['jsonrpc'] != '2.0' || + (id is! int && id is! String) || + (id is String && id.length > 128) || + rpc['params'] is! Map) { + throw const FormatException('Invalid package RPC'); + } + final method = rpc['method'], params = object(rpc['params']); + Json result; + if (method == 'app.package.info' || method == 'app.package.read') { + result = await upgrades.rpc(method as String, params); + } else if (method == 'app.upgrade.offer') { + if (_offering || + _transfer != null || + upgrades.pulling || + upgrades.state['transfer_id'] != null) { + throw StateError('Another upgrade is pending'); + } + final current = await upgrades.rpc('app.package.info', {}); + if (params['package'] != current['package'] || + params['bytes'] is! int || + (params['bytes'] as int) < 1 || + (params['bytes'] as int) > AppUpgrade.maximum || + params['version_code'] is! int || + (params['version_code'] as int) <= + (current['version_code'] as int) || + jsonEncode(params['signers']) != jsonEncode(current['signers']) || + !RegExp(r'^[a-f0-9]{64}$').hasMatch('${params['sha256']}')) { + throw const FormatException('Incompatible package offer'); + } + _offering = true; + bool accepted; + try { + accepted = await approve({ + ...params, + 'source': request.connectionInfo?.remoteAddress.address, + }).timeout(const Duration(seconds: 30), onTimeout: () => false); + } finally { + _offering = false; + } + if (!accepted || epoch != _epoch || !running) { + throw StateError('Offer not approved'); + } + result = await upgrades.rpc('app.upgrade.begin', { + 'bytes': params['bytes'], + 'sha256': params['sha256'], + }); + _transfer = result['transfer_id'] as String; + _capability = newAutomationToken(); + _expires = DateTime.now().add(const Duration(minutes: 5)); + result = {...result, 'capability': _capability}; + } else if ([ + 'app.upgrade.write', + 'app.upgrade.finish', + 'app.upgrade.abort', + 'app.upgrade.status', + ].contains(method)) { + if (_capability == null || + _transfer == null || + _expires!.isBefore(DateTime.now()) || + !sameSecret( + request.headers.value('authorization') ?? '', + 'Bearer $_capability', + ) || + params['transfer_id'] != _transfer) { + throw StateError('Transfer authorization expired or invalid'); + } + result = await upgrades.rpc(method as String, params); + if (method == 'app.upgrade.finish' || method == 'app.upgrade.abort') { + _capability = null; + _transfer = null; + _expires = null; + } + } else { + throw const FormatException( + 'Only self-package methods are exposed to peers', + ); + } + if (epoch == _epoch) { + response.write( + jsonEncode({'jsonrpc': '2.0', 'id': id, 'result': result}), + ); + } + } catch (error) { + response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': {'code': -32000, 'message': '$error'}, + }), + ); + } finally { + _active--; + await response.close(); + } + } + + Future _udp(bool mndp, int epoch) async { + final socket = await RawDatagramSocket.bind( + InternetAddress.anyIPv4, + mndp ? 5678 : 1900, + reuseAddress: true, + ); + try { + socket.broadcastEnabled = true; + socket.joinMulticast( + InternetAddress(mndp ? '224.0.0.88' : '239.255.255.250'), + ); + socket.listen((event) { + if (event != RawSocketEvent.read || epoch != _epoch) return; + Datagram? p; + while ((p = socket.receive()) != null) { + final packet = p!; + if (packet.data.length > 4096) continue; + final text = mndp + ? '' + : utf8.decode(packet.data, allowMalformed: true).toLowerCase(); + final search = mndp + ? packet.data.length == 4 && packet.data.every((b) => b == 0) + : text.startsWith('m-search * http/1.1\r\n') && + text.contains('man: "ssdp:discover"') && + (text.contains('st: ssdp:all') || + text.contains( + 'st: urn:digitalogic-ir:device:controllermobile:1', + )); + if (!search || + packet.address.isMulticast || + packet.address.isLoopback) { + continue; + } + _responded.removeWhere( + (_, at) => DateTime.now().difference(at).inSeconds > 1, + ); + final key = '${packet.address.address}|$mndp'; + if (_responded.containsKey(key) || _responded.length >= 128) continue; + _responded[key] = DateTime.now(); + if (mndp) { + socket.send(_mndp(), packet.address, packet.port); + } else { + unawaited(_ssdp(socket, packet.address, packet.port)); + } + } + }); + _sockets.add(socket); + } catch (_) { + socket.close(); + rethrow; + } + } + + Future _ssdp( + RawDatagramSocket socket, + InternetAddress source, + int destinationPort, + ) async { + // Bind each responder to its real source address so LOCATION cannot drift + // from the UDP source on multihomed Android devices. No TCP/network probe. + for (final ip in _addresses) { + if (!running) return; + RawDatagramSocket? response; + try { + response = await RawDatagramSocket.bind(ip, 0); + _sendSsdp(response, source, destinationPort, ip.address); + } on SocketException { + /* Interface disappeared; next network change refreshes. */ + } finally { + response?.close(); + } + } + } + + void _sendSsdp( + RawDatagramSocket socket, + InternetAddress source, + int destinationPort, + String ip, + ) { + if (!running) return; + socket.send( + utf8.encode( + 'HTTP/1.1 200 OK\r\nST: urn:digitalogic-ir:device:ControllerMobile:1\r\n' + 'USN: uuid:$_ssdpId\r\nLOCATION: http://$ip:$port/api/discovery\r\n' + 'X-Digitalogic-Product: controller-mobile\r\nX-Digitalogic-Name: Controller app\r\nCACHE-CONTROL: max-age=60\r\n\r\n', + ), + source, + destinationPort, + ); + } + + Uint8List _mndp() { + final identity = _instance; + final mac = sha256.convert(utf8.encode(identity)).bytes.take(6).toList(); + mac[0] = (mac[0] | 2) & 254; + final b = BytesBuilder() + ..add([0, 0, (_sequence >> 8) & 255, _sequence++ & 255]); + void field(int type, List bytes) { + b.add([type >> 8, type & 255, bytes.length >> 8, bytes.length & 255]); + b.add(bytes); + } + + field(1, mac); + field(5, utf8.encode('Controller app')); + field(8, utf8.encode('Digitalogic')); + field(11, utf8.encode(identity)); + field(12, utf8.encode('Application endpoint')); + field(16, utf8.encode('application')); + field(0xff01, utf8.encode('controller-mobile')); + field(0xff02, [port! >> 8, port! & 255]); + return b.takeBytes(); + } + + void _beacon() { + if (!running) return; + if (_expires?.isBefore(DateTime.now()) == true && _transfer != null) { + final expired = _transfer; + _capability = null; + _transfer = null; + _expires = null; + unawaited( + upgrades + .rpc('app.upgrade.abort', {'transfer_id': expired}) + .catchError((_) => {}), + ); + } + for (final s in _sockets) { + if (s.port == 5678) { + s.send(_mndp(), InternetAddress('255.255.255.255'), 5678); + s.send(_mndp(), InternetAddress('224.0.0.88'), 5678); + } + } + } + + Future close() async { + ++_epoch; + _announce?.cancel(); + _announce = null; + final server = _server; + if (server != null) { + DeviceDiscovery.localEndpoints.removeAll( + _addresses.map((ip) => 'http://${ip.address}:${server.port}'), + ); + } + _server = null; + await server?.close(force: true); + for (final s in _sockets) { + s.close(); + } + _sockets.clear(); + _responded.clear(); + _addresses.clear(); + try { + await DeviceDiscovery.channel.invokeMethod('unadvertise'); + } on Object { + /* Cleanup. */ + } + if (_transfer != null) { + try { + await upgrades.rpc('app.upgrade.abort', {'transfer_id': _transfer}); + } on Object { + /* Receipt retained. */ + } + } + _capability = null; + _transfer = null; + _expires = null; + } +} diff --git a/mobile/test/bridge_discovery_test.dart b/mobile/test/bridge_discovery_test.dart index 16c7e2334..7e48104b4 100644 --- a/mobile/test/bridge_discovery_test.dart +++ b/mobile/test/bridge_discovery_test.dart @@ -140,7 +140,43 @@ void main() { 3, ...utf8.encode('led'), ]); - expect(parseMndp(packet, '192.0.2.1')?.name, 'led'); + expect(parseMndp(packet, '192.0.2.1'), isNull); // No invented HTTP port80. + final application = Uint8List.fromList([ + ...packet, + 0, + 1, + 0, + 6, + 2, + 0, + 0, + 0, + 0, + 1, + 255, + 1, + 0, + 12, + ...utf8.encode('pccontroller'), + 255, + 2, + 0, + 2, + 0x22, + 0x53, + ]); + expect( + parseMndp(application, '192.0.2.1')?.endpoint, + 'http://192.0.2.1:8787', + ); + expect(parseMndp(application, '192.0.2.1')?.product, 'pccontroller'); + expect( + parseSsdp( + bytes('HTTP/1.1 200 OK\r\nLOCATION: http://192.0.2.2:8787/\r\n'), + source: '192.0.2.1', + ), + isNull, + ); expect(parseMndp(packet.sublist(0, 10), '192.0.2.1'), isNull); expect( parseMndp(Uint8List.fromList([...packet, 0, 5, 0, 1, 65]), '192.0.2.1'), From a0d796e8a2ed8ca6e6e22d70d841ecd8730cc6f3 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 03:17:42 +0330 Subject: [PATCH 16/72] fix: bound APK staging and cancel disposed progress notifications [skip ci] --- .../pakar/pakarcontroller/UpgradeChannel.kt | 2 ++ mobile/lib/unified/app_upgrade.dart | 23 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt index 01c2cfdbe..fd706c618 100644 --- a/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt +++ b/mobile/android/app/src/main/kotlin/ir/digitalogic/pakar/pakarcontroller/UpgradeChannel.kt @@ -130,6 +130,8 @@ class UpgradeChannel(private val activity: Activity, messenger: BinaryMessenger) // Before 0% or file creation; installation needs an additional OS copy. require(activity.cacheDir.usableSpace >= size * 2 + 64 * 1024 * 1024) { "Insufficient space for APK and Android installation" } require(root.exists() || root.mkdirs()) { "Cannot create private staging" } + val retained = root.listFiles()?.filter { it.name.matches(Regex("[a-f0-9-]{36}\\.apk")) }?.sumOf { it.length() } ?: 0 + require(retained + size <= maximum) { "Interrupted APK cache reaches its 128 MiB budget; clear this app's cache or wait for expiry" } val id = UUID.randomUUID().toString(); val file = File(root, "$id.apk") require(file.createNewFile()) { "Cannot create private transfer" } transfer = Transfer(id, file, size, hash) diff --git a/mobile/lib/unified/app_upgrade.dart b/mobile/lib/unified/app_upgrade.dart index a12bb74ef..902678419 100644 --- a/mobile/lib/unified/app_upgrade.dart +++ b/mobile/lib/unified/app_upgrade.dart @@ -27,17 +27,23 @@ class AppUpgrade extends ChangeNotifier { }; Json state = {'state': 'idle'}; bool pulling = false; + bool _disposed = false; Timer? _changed; + void _emit() { + if (!_disposed) super.notifyListeners(); + } + void _notify() { + if (_disposed) return; if (state['state'] == 'receiving' || state['state'] == 'transmitting') { _changed ??= Timer(const Duration(milliseconds: 80), () { _changed = null; - notifyListeners(); + _emit(); }); } else { _changed?.cancel(); _changed = null; - notifyListeners(); + _emit(); } } @@ -45,7 +51,7 @@ class AppUpgrade extends ChangeNotifier { channel.setMethodCallHandler((call) async { if (call.method == 'installStatus') { state = {'state': 'android_receipt', ...object(call.arguments)}; - notifyListeners(); + _emit(); } }); } @@ -113,7 +119,7 @@ class AppUpgrade extends ChangeNotifier { try { // Preflight before 0%: source identity, manifest, version, signer, space. state = {'state': 'verifying'}; - notifyListeners(); + _emit(); final health = await boundedPeerGet(client, origin.resolve('/healthz')); if (health['status'] != 'ok' || health['service'] != 'controller-mobile') { @@ -185,7 +191,7 @@ class AppUpgrade extends ChangeNotifier { } finally { pulling = false; client.close(force: true); - notifyListeners(); + _emit(); } } @@ -202,7 +208,7 @@ class AppUpgrade extends ChangeNotifier { String? transfer, capability; try { state = {'state': 'offering'}; - notifyListeners(); + _emit(); final health = await boundedPeerGet(client, origin.resolve('/healthz')); if (health['status'] != 'ok' || health['service'] != 'controller-mobile') { @@ -266,7 +272,7 @@ class AppUpgrade extends ChangeNotifier { 'endpoint': address, 'version_code': manifest['version_code'], }; - notifyListeners(); + _emit(); return result; } catch (error) { if (transfer != null && capability != null) { @@ -283,12 +289,13 @@ class AppUpgrade extends ChangeNotifier { } finally { pulling = false; client.close(force: true); - notifyListeners(); + _emit(); } } @override void dispose() { + _disposed = true; _changed?.cancel(); channel.setMethodCallHandler(null); super.dispose(); From a83b52bf35c3429236d8d4edb3ff660b769c177a Mon Sep 17 00:00:00 2001 From: David Refoua Date: Tue, 13 Oct 2026 03:17:48 +0330 Subject: [PATCH 17/72] feat(mobile): unify expert workspace, preferences and peer activities [skip ci] --- mobile/ACCEPTANCE.md | 37 + mobile/AUTOMATION.md | 5 +- mobile/README.md | 39 +- .../android/app/src/main/AndroidManifest.xml | 3 + .../pakar/pakarcontroller/MainActivity.kt | 4 + .../pakarcontroller/PreferencesChannel.kt | 17 +- mobile/lib/main.dart | 1747 +++++++++++------ mobile/lib/unified/live_widgets.dart | 52 +- mobile/lib/unified/preferences.dart | 486 +++-- mobile/lib/unified/workspace_chrome.dart | 143 ++ mobile/pubspec.yaml | 2 +- mobile/test/unified_acceptance_test.dart | 4 +- mobile/test/unified_capture_test.dart | 10 +- mobile/test/unified_widget_test.dart | 26 +- mobile/test/workspace_acceptance_test.dart | 211 ++ 15 files changed, 2014 insertions(+), 772 deletions(-) create mode 100644 mobile/lib/unified/workspace_chrome.dart create mode 100644 mobile/test/workspace_acceptance_test.dart diff --git a/mobile/ACCEPTANCE.md b/mobile/ACCEPTANCE.md index 8f25acd2a..55d873b02 100644 --- a/mobile/ACCEPTANCE.md +++ b/mobile/ACCEPTANCE.md @@ -200,3 +200,40 @@ Keep physical, renderer, source/CI and deployment claims separate. location was used to bypass it. The unused test ADB reverse was removed. - Pealayer live playback/mutual routing, physical output/programming and Android physical/tablet acceptance are not passed by the above evidence. + +## Unified expert workspace redesign, source 0.1.4+5 + +The previous deployed design is explicitly rejected. This source pass removes +separate product navigation, simultaneous peer connection/disconnection buttons, +unavailable-only telemetry cards, dormant host token inputs and empty-state +narration. Live has one compact peer strip, phone two-column numeric telemetry, +short measured sparks and a single top-level emergency-stop action. Known relay +states have one contextual header action beside the state pill and channel menu. + +Preferences now include custom color seeds and the original PakarController dark +surface/background, persistent branding, native Android task presentation and +one/both launcher aliases. Discovery integrates source-verified products, +unique vacant-role auto-connect, explicit Disconnect opt-out and Android network +changes without authority takeover. The coordinated peer upgrade modules are +wired to protected local RPC, optional sharing, incoming offer approval/expiry, +push/download, verified staging and separate permission/install/discard actions. + +Focused source checks covered contextual connection cancellation and persisted +opt-out, sparse telemetry, dormant auth, native task adapter arguments, original +Pakar colors, RTL/enlarged text, current cue ownership, sliders, trailing channel +indicators, front-panel modes, unsupported LCD disappearance and update permission/ +installer receipt semantics. Analyzer and Android Kotlin compilation passed. +Controlled phone/tablet renders use a recorded Cafe snapshot, 12 read-only measured +telemetry samples and generic Pealayer media fixtures. These are renderer previews, +not screenshots of deployed Android interaction or physical/audio acceptance. + +An inherited suite run exposed obsolete UI expectations and the old unverified +MNDP fixture. Their current contracts were repaired with owner-scoped changes. +Further full inherited suites/CI were not repeated during this iteration. No +all-green CI or all-device acceptance claim is made. + +Deployment remains held for design review. The tablet still has 0.1.3+4; the phone +still has 0.1.2+3 after its prior 0.1.3 installer-space rejection. The phone's free +space changed since that attempt, but no new installation was attempted. Both +screens were locked at the latest check. Standalone shared-core packaging/USB, +full native/Web parity and physical mirroring/peer/firmware gates remain open. diff --git a/mobile/AUTOMATION.md b/mobile/AUTOMATION.md index b9cce9ae0..3f1d4a747 100644 --- a/mobile/AUTOMATION.md +++ b/mobile/AUTOMATION.md @@ -63,9 +63,8 @@ use WebSocket for push, including live front-panel/status state. Read/UI methods: `app.ping`, `app.capabilities`, `app.state`, `app.subscribe`, `app.navigation.set`, `app.capture`, `board.refresh`. -Navigation takes `section` (`pccontroller`, `pealayer`, `activities`, `workspace`) -and an optional PCController `page` (`monitor`, `outputs`, `front_panel`, -`firmware`, `effects`). Capture returns an app-only PNG, base64, width/height; +Navigation takes `activity` (`live`, `channels`, `panel`, `studio`, `devices`, +`firmware`, `events`) in the unified workspace. Capture returns an app-only PNG, base64, width/height; HTTP recommended. No desktop/other-app screen is captured. Max one capture per second and 2 MiB PNG; capture is rejected over Intent to avoid Binder limits. diff --git a/mobile/README.md b/mobile/README.md index 83fadce6d..a9387a0a6 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,7 +1,7 @@ # PCController native controller An independently packaged Flutter Android phone/tablet app with PCController -and Pealayer mini-apps. It is a native Flutter surface, not an embedded Web page. +and Pealayer activities in one workspace. It is a native Flutter surface. PakarController remains a separate project and Android installation. ## Activities @@ -25,16 +25,33 @@ PakarController remains a separate project and Android installation. - Activities: bounded in-memory host events and request receipts. Request acceptance is not reported as physical completion or applied playback. -Phones use bottom navigation; tablets use a rail and responsive multi-column -cards. System light/dark preference and platform text scaling are retained. -Settings also selects Light/Dark explicitly, layout and control density. The -PCController and Pealayer launcher icons may be enabled separately or together; -both open their own section of the same app. Android applies component changes -from the app's own UID without restarting it. Beep mirroring is explicitly -enabled in Settings and stops on backgrounding, loss of the selected peer or -generation change; actual audible/timing acceptance is tracked separately. -The neutral card/action styling shares Pealayer's purple accent and semantic -state indicators rather than coloring every action as selected. Relay, motion +Phones use a compact activity strip; tablets use an activity sidebar with +responsive multi-column cards. Live combines PCController telemetry and Pealayer +playback. Channels, Panel, Studio, Devices, Firmware and Events share the same +workspace. The connection strip uses one Connect, Cancel connection or Disconnect +action per peer. Unavailable-only cards and dormant remote token inputs are omitted. + +Preferences select System/Light/Dark, layout, density, Digitalogic/Pealayer/ +original PakarController/Amber palettes or a custom color seed. Branding updates +the app title and Android task icon/title. One or both launcher aliases may be +enabled; both open the same unified workspace. The app's own UID applies aliases +without restarting it. Native task/launcher behavior still requires device checks. +Beep mirroring is explicitly enabled in Preferences and stops on backgrounding, +loss of the selected peer or generation change. Transient audio failures preserve +the saved mirroring intent; actual audibility/timing acceptance remains separate. + +Saved automatic discovery connects only one verified candidate for a vacant role; +ambiguous advertisements, manual selections and explicit Disconnect opt-outs are +preserved. Android network change notifications trigger bounded rescans for vacant +roles. Discovery never takes physical authority or actuates an output. + +Devices provides explicit peer package sharing, push/download and verified +staging actions. Incoming offers show source/package/version/digest/signers and +expire without approval. Installation requires a separate action and Android +consent. Protected local automation tokens remain in force. See +[Peer upgrades](#unified-workspace-discovery-and-self-package-rpc) for the transport and package checks. + +Relay, motion and PWM cards distinguish observed state from commands/setpoints; stale values become Unknown. In-memory voltage/current/power and both temperature histories retain at most 600 observations (at most 2 Hz), with gaps for missing readings. diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 818d7a015..21c7f807a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + +