diff --git a/.github/scripts/repository-policy.mjs b/.github/scripts/repository-policy.mjs index 4168dc8bb..14924a10d 100644 --- a/.github/scripts/repository-policy.mjs +++ b/.github/scripts/repository-policy.mjs @@ -22,6 +22,9 @@ const policyDefinitionPaths = new Set([ // Current-product links are permitted only in maintained public entry points. const repositoryIdentityPaths = new Set([ + "docs/WEB-HOST-TERMINAL.md", + "docs/verification/serial-response-deadlines.md", + "docs/verification/web-channel-controls.md", "README.md", "docs/Alpha-Delivery-Ledger.md", "docs/CI-CD-and-Releases.md", @@ -36,6 +39,7 @@ const repositoryIdentityPaths = new Set([ // repository; private paths, stale-origin language and unrelated URLs remain // forbidden. const reviewedExternalRepositories = new Map([ + ["docs/verification/serial-response-deadlines.md", new Set(["toghroltp/pealayer"])], ["mobile/assets/fonts/Vazirmatn-OFL.txt", new Set(["rastikerdar/vazirmatn"])], ["mobile/PAKAR-README.md", new Set(["atomicdeploy/pakarcontroller"])], ["docs/Shared-Production-Firmware.md", new Set(["mcudude/megacore"])], 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 diff --git a/.gitignore b/.gitignore index 968410979..7db0a5e02 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ yarn-error.log* pnpm-debug.log* # MCU/virtual-board runtime state and backups +/build-programs/ virtual-mcu-eeprom.bin *.eeprom *.backup diff --git a/AGENTS.md b/AGENTS.md index 753d44d57..4f2a87a69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,3 +30,21 @@ ## 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 action per peer. Connect/cancel may be first-class while disconnected/connecting; The latest direct request uses one compact lifecycle toggle: Connect, Cancel while connecting, or Disconnect when connected. Never show competing connection verbs together. Preserve explicit disconnect discovery opt-out. 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. + +- The latest contextual-utility rejection remains authoritative: no idle effect Cancel/Stop actions, empty live-only cards, unavailable-only fact cards or static "last values unavailable" narration. Keep actionable exceptions and useful offline editors. Hardware controls use balanced equal-width action rows within compact cards, semantic ON/OFF colors and directly clickable observed relay indicators with the same current-owner/lock/E-STOP gates. Folder assignment is a catalog-driven dropdown scoped to output kind and preserving raw folder identity, not a plain text field. Refresh is explicit and event-driven; only failed/stale/unsupported WebSocket transport uses bounded fallback reads. Preserve acknowledged PWM drafts, current profile-resolved names, and owner/revision fences. Native network discovery, auto-connect intent, branding/task icons and dual launcher aliases, all existing color schemes and integrity-checked peer IPC/RPC app transfers remain acceptance requirements. Defer remote multi-step authentication until supported; hide unused remote token fields without weakening protected local automation. Every new surface follows this contract without another user reminder. + +- Live continuous values retain a quiet final update per domain; do not drop it through throttling or collapse RGB/PWM into one shared latest slot. Complete calibrated push observations carry selected host, board generation, monotonic revision and time. Buffer bounded notifications until subscription ACK; fence replaced owners and older reads, and preserve active edits/acknowledged setpoints. Healthy complete pushes must not trigger per-client HTTP/PWM polling. Audio/segments/lifecycle stay immediate. Typed request/response relay must retain honest bounded fallback rather than treating player WS as a controller subscription. Record Android installed identity per reachable device separately from source, physical, audio and human acceptance. + +- Apply the folder-dropdown contract to effect/Studio authoring as well as hardware assignments. Preserve current raw category identity; creating a new folder is a separate explicit action. Effect/cue/settings dialogs share bounded keyboard-aware chrome, explicit Close/Cancel and fixed busy progress space. Invalid numeric edits survive selection changes and block saving; never silently retain the old number behind invalid input. Unapplied technical JSON must not be overwritten by typed controls. Pending saves disable editable fields; failures preserve drafts and unchanged Save sends no mutation. Resource URL normalization changes authority characters only, retaining native paths, query and Unicode filename semantics. Keep mobile/UI-REQUIREMENT-LEDGER.md current and never mark all UI/hardware accepted from source fixtures or delivery. 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/IlluminationController.cpp b/Project/IlluminationController.cpp index c34ff11b0..bb11745db 100644 --- a/Project/IlluminationController.cpp +++ b/Project/IlluminationController.cpp @@ -17,9 +17,7 @@ void IlluminationController::begin(PwmController &pwm, bool doorOpen, pwm_ = &pwm; currentBrightness_ = mode_ == IlluminationMode::On ? onBrightness_ : offBrightness_; - pwm_->setEnclosureIllumination( - static_cast(currentBrightness_) * 16U + - currentBrightness_ / 16U); + pwm_->setEnclosureIllumination(TransitionMath::expandByte12(currentBrightness_)); lastFadeAt_ = now; initialized_ = true; @@ -56,8 +54,7 @@ void IlluminationController::service(bool doorOpen, bool allowLedUpdate, // jitter while preserving the responsive 20 ms control cadence. lastFadeAt_ = now; const uint8_t next = TransitionMath::easedByte(currentBrightness_, target); - if (pwm_->setEnclosureIllumination( - static_cast(next) * 16U + next / 16U)) { + if (pwm_->setEnclosureIllumination(TransitionMath::expandByte12(next))) { currentBrightness_ = next; } } 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/PwmController.cpp b/Project/PwmController.cpp index d92602504..ed9fb3086 100644 --- a/Project/PwmController.cpp +++ b/Project/PwmController.cpp @@ -1,4 +1,5 @@ #include "PwmController.h" +#include "TransitionMath.h" namespace { @@ -292,6 +293,5 @@ void PwmController::tripUnavailable() { uint16_t PwmController::from8Bit(uint8_t value) { // Exact endpoint-preserving 8-bit to 12-bit expansion. - return static_cast(static_cast(value) * 16U + - value / 16U); + return TransitionMath::expandByte12(value); } diff --git a/Project/Runtime/ControllerContext.inc.h b/Project/Runtime/ControllerContext.inc.h index 6958216d3..e9a23908c 100644 --- a/Project/Runtime/ControllerContext.inc.h +++ b/Project/Runtime/ControllerContext.inc.h @@ -161,6 +161,10 @@ uint8_t lastPushedBuzzerRevision = 0; uint8_t lastPushedStatusLed[6] = {}; uint8_t lastStatusLedPushAt = 0; +uint8_t lastPushedPwmValues[34] = {}; +bool havePushedPwmValues = false; +uint32_t lastPwmPushAt = 0; + // Host-captured panel text, LCD fallback metadata, and cooperative I2C lease. bool hostSegmentTextActive = false; // DISPLAY_TEXT reuses one 40-byte buffer for static text or a scheduled scroll. diff --git a/Project/Runtime/ControllerUtilities.inc.h b/Project/Runtime/ControllerUtilities.inc.h index f87147d8b..d90a17604 100644 --- a/Project/Runtime/ControllerUtilities.inc.h +++ b/Project/Runtime/ControllerUtilities.inc.h @@ -29,8 +29,8 @@ int32_t scaledMilliValue(int32_t value, uint8_t decimalPlaces) { const uint16_t divisor = decimalPlaces == 0 ? 1000U : (decimalPlaces == 1 ? 100U : 10U); const int32_t rounding = divisor / 2U; - return value >= 0 ? (value + rounding) / divisor - : (value - rounding) / divisor; + // Select the signed rounding adjustment once before the wide division. + return (value + (value >= 0 ? rounding : -rounding)) / divisor; } // Loads EEPROM once, then mirrors persisted illumination fields into the controller. @@ -103,8 +103,14 @@ void showHostOfflineOnLcd() { // Appends little-endian integers without allocating a serializer object. void appendU16(uint8_t *buffer, uint8_t &index, uint16_t value) { - buffer[index++] = static_cast(value); - buffer[index++] = static_cast(value >> 8); + // The caller-owned cursor is separate from the reply buffer. Publish it + // once after both bytes rather than forcing two aliased reference stores. + // Every reply builder reserves both bytes inside its bounded UART payload. + const uint8_t offset = index; + uint8_t *const output = buffer + offset; + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + index = static_cast(offset + 2U); } // Appends a signed 16-bit value with the same little-endian wire representation. diff --git a/Project/Runtime/FrontPanelRuntime.inc.h b/Project/Runtime/FrontPanelRuntime.inc.h index 6ca2c4e59..9875485d0 100644 --- a/Project/Runtime/FrontPanelRuntime.inc.h +++ b/Project/Runtime/FrontPanelRuntime.inc.h @@ -3,11 +3,19 @@ // Menu, keys, display, and buzzer // ----------------------------------------------------------------------------- +// Share the same fixed label dwell arithmetic across its four call sites. +#if defined(__AVR__) +__attribute__((noinline)) +#endif +void holdMenuLabel(uint32_t at) { + menuLabelEndsAt = at + 650; +} + // Shows one packed four-character label for a short dwell. void showPackedLabel(const char *labels, uint8_t index, uint32_t at) { display.showText(reinterpret_cast( labels + static_cast(index << 2))); - menuLabelEndsAt = at + 650; + holdMenuLabel(at); } // Shows an ordinary menu label from its compile-selected storage. EEPROM labels @@ -18,7 +26,7 @@ void showMenuLabel(uint8_t index, uint32_t at) { char label[EepromMenuLabels::LabelWidth]; EepromMenuLabels::copy(index, label); display.showText(label); - menuLabelEndsAt = at + 650; + holdMenuLabel(at); #else showPackedLabel(MenuLabels, index, at); #endif @@ -204,7 +212,7 @@ void programService(uint32_t at) { if (editLabel < EditLabelCount) { display.showText(reinterpret_cast( EditLabels + static_cast(editLabel << 2))); - menuLabelEndsAt = at + 650; + holdMenuLabel(at); return; } @@ -367,8 +375,7 @@ void setSilentMode(bool silent, uint32_t at) { // Expands an EEPROM 8-bit user value over the full 12-bit PWM range. uint16_t userPwm12(uint8_t value) { - return static_cast( - static_cast(value) * 16U + value / 16U); + return TransitionMath::expandByte12(value); } // Applies all persisted live settings without changing their EEPROM ownership. @@ -838,7 +845,7 @@ void handleMenuAction(uint8_t action, bool fromRemote) { #endif } else { display.showText(commonText(TextError)); - menuLabelEndsAt = actionNow + 650; + holdMenuLabel(actionNow); statusLeds.playCue(StatusLedCue::Discard, 650, actionNow); return; } diff --git a/Project/Runtime/LifecycleRuntime.inc.h b/Project/Runtime/LifecycleRuntime.inc.h index cf3fdd1f1..7f7ac01a1 100644 --- a/Project/Runtime/LifecycleRuntime.inc.h +++ b/Project/Runtime/LifecycleRuntime.inc.h @@ -293,6 +293,7 @@ __attribute__((noinline)) void serviceController() { #endif buzzer.update(loopNow); serviceBuzzerPush(); + servicePwmPush(loopNow); if (streamPeriodMs != 0 && static_cast(loopNow - lastTelemetryAt) >= streamPeriodMs) { diff --git a/Project/Runtime/ProtocolRuntime.inc.h b/Project/Runtime/ProtocolRuntime.inc.h index ab779f2d7..6cdfaf47d 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 @@ -256,6 +259,29 @@ void serviceStatusLedPush() { sizeof(payload)); } +// Publish applied logical values, including local/front-panel/effect changes. +// Compare against the last SENT state, so the final change survives throttling. +// Sequence zero is an observation, never a request or another output write. +void servicePwmPush(uint32_t at) { + if (havePushedPwmValues && static_cast(at - lastPwmPushAt) < 50U) { + return; + } + uint8_t payload[34]; + uint8_t index = 0; + payload[index++] = pwm.available() ? 1U : 0U; + payload[index++] = pwm.channel(); + for (uint8_t channel = 0; channel < PwmChannels::Count; ++channel) { + appendU16(payload, index, pwm.logicalValue(channel)); + } + if (havePushedPwmValues && memcmp(payload, lastPushedPwmValues, sizeof(payload)) == 0) { + return; + } + memcpy(lastPushedPwmValues, payload, sizeof(payload)); + havePushedPwmValues = true; + lastPwmPushAt = at; + appProtocol.send(ControllerProtocol::PwmValuesResponse, 0, payload, sizeof(payload)); +} + #if PCCONTROLLER_ENABLE_MENU_DIRECTORY // Reports every stable direct-selector ID. PAGE_MOTION is a non-browsable // compatibility alias, but stays in the complete directory so persisted diff --git a/Project/TransitionMath.h b/Project/TransitionMath.h index e0ce0a90e..7e01307d2 100644 --- a/Project/TransitionMath.h +++ b/Project/TransitionMath.h @@ -5,6 +5,11 @@ // Small, allocation-free transition helpers shared by front-panel domains. namespace TransitionMath { +// Exact endpoint-preserving byte expansion shared by every PWM/fade caller. +inline uint16_t expandByte12(uint8_t value) { + return static_cast(static_cast(value) * 16U + value / 16U); +} + // Rolls a byte setting through both electrical endpoints before wrapping. inline uint8_t rollByte(uint8_t value, uint8_t step, bool increase) { if (increase) { @@ -34,12 +39,11 @@ inline uint8_t easedByte(uint8_t current, uint8_t target) { } else if (step > 8) { step = 8; } - if (current < target) { - const uint16_t next = static_cast(current) + step; - return static_cast(next > target ? target : next); - } - return static_cast(current - target > step ? current - step - : target); + // For every nonzero distance, 1 <= step <= distance. Neither direction can + // overshoot its target or wrap an electrical endpoint, so a second 16-bit + // addition and redundant endpoint clamps need not occupy AVR flash. + return current < target ? static_cast(current + step) + : static_cast(current - step); } // Damps one 12-bit RGB channel while guaranteeing eventual convergence. 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/Tools/Audit/generate-api-reference.mjs b/Tools/Audit/generate-api-reference.mjs index 6116e92eb..fde60bef0 100644 --- a/Tools/Audit/generate-api-reference.mjs +++ b/Tools/Audit/generate-api-reference.mjs @@ -91,6 +91,7 @@ const capabilityGroups = { shutdown: ["controller.quit", "controller.exit"], messages: ["controller.message.send"], host_configuration: [ + "controller.udp.config.set", "controller.udp.subscribe", "controller.udp.unsubscribe", "controller.mirror.configure", "controller.host_menu.configure", "controller.host_menu.config.set", "controller.ui.config.set", "controller.peripherals.set", "controller.board_profile.update", "controller.peripheral.presentation.update", @@ -104,7 +105,7 @@ const capabilityGroups = { ], virtual_keys: ["controller.os.key", "controller.virtual_key"], power_actions: ["controller.os.power"], - bridge_calls: ["controller.bridge.call", "controller.discovery.connect"], + bridge_calls: ["controller.bridge.call", "controller.discovery.connect", "controller.udp.call"], integrations: [ "controller.audio.play", "controller.device.status", "controller.device.action", "controller.device.inspect", @@ -113,6 +114,7 @@ const capabilityGroups = { "controller.discovery.config.set", ], read: [ + "controller.udp.config.get", "controller.udp.status", "controller.udp.events.get", "controller.mirror.inspect", "controller.mirror.status", "controller.audio.cues", "controller.artifact.manifest", "controller.artifact.list", "controller.update.status", @@ -284,6 +286,7 @@ const routes = [ { path: "/api/session/ticket", methods: ["post"], capability: "session", summary: "Dormant alpha compatibility endpoint; returns 409 while application authentication is disabled" }, { path: "/api/rpc", methods: ["post"], capability: "dynamic", summary: "JSON-RPC 2.0 request" }, { path: "/api/snapshot", methods: ["get"], capability: "read", summary: "Authoritative cached controller snapshot" }, + { path: "/api/diagnostics/cpu-profile", methods: ["post"], capability: "local-only", binaryResponse: true, summary: "Explicit five-second application CPU profile; loopback callers only, one active capture, no board actions or request body" }, { path: "/api/estop", methods: ["get"], capability: "read", summary: "Authoritative effect and motion interlock state" }, { path: "/api/estop", methods: ["put", "post"], capability: "board_commands", summary: "Engage or release the effect and motion interlock" }, { path: "/api/peripherals", methods: ["get"], capability: "read", summary: "Peripheral descriptors and host-owned names" }, @@ -460,7 +463,9 @@ function operationFor(route, method) { "x-required-capability": route.capability, "x-idempotency": ["get", "head"].includes(method) ? "safe" : "see operation semantics and idempotency keys", responses: { - "200": { description: "Accepted response", content: { "application/json": { schema: { type: "object", additionalProperties: true } } } }, + "200": route.binaryResponse + ? { description: "Five-second gzip-encoded Go CPU profile", content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } } } + : { description: "Accepted response", content: { "application/json": { schema: { type: "object", additionalProperties: true } } } }, "400": { $ref: "#/components/responses/BadRequest" }, "401": { $ref: "#/components/responses/Unauthorized" }, "403": { $ref: "#/components/responses/Forbidden" }, diff --git a/Tools/Build/build.mjs b/Tools/Build/build.mjs index aa397a89c..57babb9fa 100644 --- a/Tools/Build/build.mjs +++ b/Tools/Build/build.mjs @@ -771,7 +771,7 @@ export function createPlan(options, identity, platform = process.platform) { } if (!options.cleanOnly && options.host) { actions.push({ id: 'embedded-defaults', stage: 'Stage the exact validated firmware and safe EEPROM pair for host embedding', hardware: false }) - actions.push(commandAction('web-install', 'Install locked web dependencies', 'npm', ['ci', '--no-audit', '--no-fund'], WEB_ROOT)) + actions.push(commandAction('web-install', 'Verify locked web dependencies; install if cache is invalid', 'npm', ['ci', '--no-audit', '--no-fund'], WEB_ROOT)) actions.push(commandAction('web-typecheck', 'Type-check embedded web application', 'npm', ['run', 'typecheck'], WEB_ROOT)) if (options.tests) actions.push(commandAction('web-test', 'Run embedded web tests', 'npm', ['run', 'test', '--', '--passWithNoTests'], WEB_ROOT)) actions.push(commandAction('web-build', 'Build embedded web application', 'npm', ['run', 'build'], WEB_ROOT)) @@ -1198,6 +1198,35 @@ function verifyEmbeddedWebBuild(expectedAppName) { return identity } +export function reusableWebDependencies(webRoot = WEB_ROOT) { + try { + const lock = JSON.parse(readFileSync(join(webRoot, 'package-lock.json'), 'utf8')) + const declared = JSON.parse(readFileSync(join(webRoot, 'package.json'), 'utf8')) + const hiddenPath = join(webRoot, 'node_modules', '.package-lock.json') + const installed = JSON.parse(readFileSync(hiddenPath, 'utf8')) + const hiddenTime = statSync(hiddenPath).mtimeMs + if (lock.lockfileVersion < 3 || installed.lockfileVersion < 3 || !lock.packages || !installed.packages) return false + const spec = value => JSON.stringify(Object.entries(value || {}).sort(([left], [right]) => left.localeCompare(right))) + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + if (spec(declared[field]) !== spec(lock.packages['']?.[field])) return false + } + const actual = Object.entries(installed.packages) + if (!actual.length || actual.length > 10000) return false + for (const [path, metadata] of actual) { + if (!path.startsWith('node_modules/') || path.split('/').some(part => part === '..' || part === '.' || !part)) return false + const expected = lock.packages[path] + if (!expected || metadata.link || expected.link || ['version', 'resolved', 'integrity'].some(field => metadata[field] !== expected[field])) return false + const directory = join(webRoot, ...path.split('/')) + if (!statSync(directory).isDirectory() || statSync(directory).mtimeMs > hiddenTime) return false + if (JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')).version !== expected.version) return false + } + for (const [path, metadata] of Object.entries(lock.packages)) { + if (path.startsWith('node_modules/') && !metadata.optional && !installed.packages[path]) return false + } + return true + } catch { return false } +} + function buildWebUI(options, env, log, expectedAppName) { assertNodeVersion(MINIMUM_WEB_NODE, 'the embedded web build') if (!existsSync(WEB_LOCK)) { @@ -1221,8 +1250,9 @@ function buildWebUI(options, env, log, expectedAppName) { runGoBuildHelper(go, 'generate-icon', './winres/generate_icon.go', ['./winres/icon.png', './winres/icon.ico'], env, options) copyFileSync(join(HOST_ROOT, 'winres', 'icon.ico'), join(WEB_ROOT, 'public', 'favicon.ico')) const inputsBefore = directoryIdentity(WEB_ROOT, true) - log.stage('🔒', 'Installing locked web dependencies') - invoke(['ci', '--no-audit', '--no-fund']) + const reuseDependencies = reusableWebDependencies() + log.stage('🔒', reuseDependencies ? 'Reusing verified locked web dependencies' : 'Installing locked web dependencies') + if (!reuseDependencies) invoke(['ci', '--no-audit', '--no-fund']) log.stage('🧭', 'Type-checking embedded web application') invoke(['run', 'typecheck']) if (options.tests) { @@ -1238,7 +1268,7 @@ function buildWebUI(options, env, log, expectedAppName) { const dist = verifyEmbeddedWebBuild(expectedAppName) log.detail(`embedded web: ${dist.files} files, SHA256 ${dist.sha256}`) return { - dependencies: 'npm-ci', + dependencies: reuseDependencies ? 'locked-cache-verified' : 'npm-ci', typecheck: 'passed', tests: options.tests ? 'passed' : 'skipped', build: 'verified', diff --git a/Tools/Build/web-cache.test.mjs b/Tools/Build/web-cache.test.mjs new file mode 100644 index 000000000..26989bfbe --- /dev/null +++ b/Tools/Build/web-cache.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { reusableWebDependencies } from './build.mjs' + +test('reuse requires matching declarations, locked graph and installed versions', async () => { + const root = await mkdtemp(join(tmpdir(), 'pccontroller-web-cache-')) + try { + const packages = { '': { dependencies: { sample: '1.0.0' } }, 'node_modules/sample': { version: '1.0.0', resolved: 'https://example.invalid/sample.tgz', integrity: 'sha512-fixture' } } + await mkdir(join(root, 'node_modules/sample'), { recursive: true }) + await writeFile(join(root, 'package.json'), JSON.stringify({ dependencies: { sample: '1.0.0' } })) + await writeFile(join(root, 'package-lock.json'), JSON.stringify({ lockfileVersion: 3, packages })) + await writeFile(join(root, 'node_modules/sample/package.json'), JSON.stringify({ version: '1.0.0' })) + await writeFile(join(root, 'node_modules/.package-lock.json'), JSON.stringify({ lockfileVersion: 3, packages: { 'node_modules/sample': packages['node_modules/sample'] } })) + assert.equal(reusableWebDependencies(root), true) + await writeFile(join(root, 'node_modules/sample/package.json'), JSON.stringify({ version: '2.0.0' })) + assert.equal(reusableWebDependencies(root), false) + await writeFile(join(root, 'package.json'), JSON.stringify({ dependencies: { sample: '3.0.0' } })) + assert.equal(reusableWebDependencies(root), false) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/Tools/Controller/api/jsonrpc.schema.json b/Tools/Controller/api/jsonrpc.schema.json index c80eb8ad0..bb5431a3b 100644 --- a/Tools/Controller/api/jsonrpc.schema.json +++ b/Tools/Controller/api/jsonrpc.schema.json @@ -542,6 +542,13 @@ "controller.subscribe", "controller.system.status", "controller.temperatures", + "controller.udp.call", + "controller.udp.config.get", + "controller.udp.config.set", + "controller.udp.events.get", + "controller.udp.status", + "controller.udp.subscribe", + "controller.udp.unsubscribe", "controller.ui.config", "controller.ui.config.get", "controller.ui.config.set", @@ -1552,6 +1559,41 @@ "summary": "temperatures", "idempotency": "safe" }, + "controller.udp.call": { + "capability": "bridge_calls", + "summary": "udp call", + "idempotency": "idempotent-with-authoritative-readback" + }, + "controller.udp.config.get": { + "capability": "read", + "summary": "udp config get", + "idempotency": "safe" + }, + "controller.udp.config.set": { + "capability": "host_configuration", + "summary": "udp config set", + "idempotency": "idempotent-with-authoritative-readback" + }, + "controller.udp.events.get": { + "capability": "read", + "summary": "udp events get", + "idempotency": "safe" + }, + "controller.udp.status": { + "capability": "read", + "summary": "udp status", + "idempotency": "safe" + }, + "controller.udp.subscribe": { + "capability": "host_configuration", + "summary": "udp subscribe", + "idempotency": "idempotent-with-authoritative-readback" + }, + "controller.udp.unsubscribe": { + "capability": "host_configuration", + "summary": "udp unsubscribe", + "idempotency": "idempotent-with-authoritative-readback" + }, "controller.ui.config": { "capability": "read", "summary": "Return host-owned UI settings, including status_interval_ms and measurement_freshness_ms.", diff --git a/Tools/Controller/api/openapi.json b/Tools/Controller/api/openapi.json index e9f5ac432..cfcc4d876 100644 --- a/Tools/Controller/api/openapi.json +++ b/Tools/Controller/api/openapi.json @@ -287,6 +287,53 @@ } } }, + "/api/diagnostics/cpu-profile": { + "post": { + "operationId": "post_api_diagnostics_cpu_profile", + "summary": "Explicit five-second application CPU profile; loopback callers only, one active capture, no board actions or request body", + "tags": [ + "cpu-profile" + ], + "x-required-capability": "local-only", + "x-idempotency": "see operation semantics and idempotency keys", + "responses": { + "200": { + "description": "Five-second gzip-encoded Go CPU profile", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "405": { + "$ref": "#/components/responses/MethodNotAllowed" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, "/api/estop": { "get": { "operationId": "get_api_estop", @@ -4081,6 +4128,13 @@ "controller.subscribe", "controller.system.status", "controller.temperatures", + "controller.udp.call", + "controller.udp.config.get", + "controller.udp.config.set", + "controller.udp.events.get", + "controller.udp.status", + "controller.udp.subscribe", + "controller.udp.unsubscribe", "controller.ui.config", "controller.ui.config.get", "controller.ui.config.set", diff --git a/Tools/Controller/api/reference.html b/Tools/Controller/api/reference.html index f3c890bfa..b2b243b2d 100644 --- a/Tools/Controller/api/reference.html +++ b/Tools/Controller/api/reference.html @@ -2,7 +2,7 @@ PCController API Reference -
OFFLINE CONTRACT · LIVING API

PCController API

The primary host exposes one unversioned, safety-gated living surface across REST, JSON-RPC, WebSocket, and the bounded Socket.IO adapter. Loopback is the default; immediate-alpha application authentication and authorization are disabled under issue #148.

OpenAPI 3.1AsyncAPI 3.0JSON-RPC schema177 RPC methods87 HTTP operations
-

HTTP operations

Canonical routes live directly under /api/. JSON bodies are capped at 1 MiB.

MethodPathPurposeCapability
GET/healthzService liveness and API identitypublic
GET/upnp/public.jsonBounded public host, board, endpoint, health, and telemetry directorypublic
GET/api/ui-configNon-secret browser bootstrappublic
GET/api/auth/server-proofDormant alpha compatibility endpoint; returns 409 while application authentication is disabledpublic
POST/api/session/ticketDormant alpha compatibility endpoint; returns 409 while application authentication is disabledsession
POST/api/rpcJSON-RPC 2.0 requestdynamic
GET/api/snapshotAuthoritative cached controller snapshotread
GET/api/estopAuthoritative effect and motion interlock stateread
PUT/api/estopEngage or release the effect and motion interlockboard_commands
POST/api/estopEngage or release the effect and motion interlockboard_commands
GET/api/peripheralsPeripheral descriptors and host-owned namesread
PUT/api/peripheralsReplace host-owned peripheral nameshost_configuration
GET/api/pwmAuthoritative PWM stateread
PUT/api/pwmMutate PWM state with board readbackboard_commands
DELETE/api/pwmMutate PWM state with board readbackboard_commands
GET/api/commandsShared command catalogread
GET/api/program-stateHost-owned program stateread
PUT/api/program-stateUpdate host-owned program stateboard_commands
POST/api/program-stateUpdate host-owned program stateboard_commands
GET/api/menu/catalogLive board menu catalogread
GET/api/menu/layoutLive board menu layoutread
PUT/api/menu/layoutPersist a validated board menu layoutboard_commands
POST/api/menu/layoutPersist a validated board menu layoutboard_commands
GET/api/host-menusHost-presented menu directoryread
PUT/api/host-menusReplace the host-presented menu directoryhost_configuration
POST/api/host-menusReplace the host-presented menu directoryhost_configuration
GET/api/os/statusHost status and policyread
GET/api/os/factsBounded read-only host factsread
POST/api/os/keyValidated virtual-key actionvirtual_keys
POST/api/os/powerConfirmed system or display actionpower_actions
POST/api/commandShared command dispatcherdynamic
POST/api/messagesTyped source-tagged messagemessages
POST/api/displayPresent arbitrary seven-segment or LCD text with scroll and repeat timingboard_commands
POST/api/opcodeExchange an opaque versionless UART opcodeboard_commands
GET/api/app/bridgeOriginal coordinator bridge instance and process identityread
GET/api/app/instancesList or query live application instancesread
POST/api/app/instancesReport or remove one live application instancehost_configuration
DELETE/api/app/instancesReport or remove one live application instancehost_configuration
POST/api/app/actionFreeze and route a correlated action to exact live application instanceshost_configuration
POST/api/app/action/ackAcknowledge one exact app-action target outcomehost_configuration
GET/api/app/action/outcomeRead one bounded app-action operation outcomeread
POST/api/app/navigateNavigate all, one surface, or one exact application instancehost_configuration
POST/api/app/launchEnsure, launch, or focus a named TUI or WebUI surfacehost_configuration
GET/api/bridgesConfigured bridge stateread
POST/api/bridges/callCorrelated bridge callbridge_calls
GET/api/artifacts/manifestArtifact/default/current manifestread
GET/api/artifactsContent-addressed artifact catalogread
POST/api/artifacts/uploadImport a verified artifactprogramming
POST/api/artifacts/fetchQueue a bounded remote artifact fetchprogramming
POST/api/artifacts/captureCapture flash or EEPROM explicitlyprogramming
GET/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
HEAD/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
GET/api/artifacts/current/{kind}Latest explicitly captured artifactread
HEAD/api/artifacts/current/{kind}Latest explicitly captured artifactread
POST/api/board/firmware/uploadQueue a guarded staged-firmware transfer to the boardprogramming
GET/api/board/firmware/downloadDownload the latest verified board flash readbackread
HEAD/api/board/firmware/downloadDownload the latest verified board flash readbackread
POST/api/board/firmware/downloadQueue a fresh verified board flash readbackprogramming
POST/api/updates/{kind}Queue an explicitly authorized updateprogramming
POST/api/restores/flashQueue an explicitly authorized flash restoreprogramming
GET/api/updates/status/{id}Update operation progressread
POST/api/discovery/github/workflowDiscover workflow artifactsread
POST/api/discovery/github/releaseDiscover release assetsread
GET/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/checkCompare candidate and installed identitiesread
POST/api/discovery/stageStage a verified candidate without programmingprogramming
GET/api/discovery/status/{id}Discovery or staging progressread
POST/api/webhooks/inboundOptional inbound typed webhookmessages
GET/api/webhooks/outbound/statusOutbound delivery queue and dead-letter statusread
GET/api/webhooks/outbound/pendingBounded non-secret pending delivery listread
GET/api/webhooks/outbound/deadBounded non-secret dead-letter listread
POST/api/webhooks/outbound/replayReplay explicitly selected dead-letter deliveriesintegrations
POST/api/webhooks/outbound/clearClear explicitly selected dead-letter deliveriesintegrations
GET/api/integrations/statusRequested and effective buzzer routing and playback stateread
GET/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
HEAD/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
POST/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PUT/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PATCH/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
DELETE/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
GET/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
HEAD/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
POST/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PUT/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PATCH/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
DELETE/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
-

JSON-RPC methods

Standard JSON-RPC errors are preserved; host extensions use -32001 for authentication, -32003 for capability denial, and -32000 for runtime or device failures.

MethodPurposeCapabilityIdempotency
controller.action.invokeInvoke one stable action ID advertised by the attached board profile and wait for the board acknowledgement.board_commandsnon-idempotent
controller.app.actionFreeze a validated action to exact live targets and return its correlated per-target delivery operation.dynamicnon-idempotent
controller.app.action.ackAcknowledge one exact app-action target as applied or rejected.host_configurationidempotent-with-authoritative-readback
controller.app.action.outcomeReturn one bounded app-action operation and its exact per-target outcomes.readsafe
controller.app.bridgeReturn the original coordinator bridge instance and its bounded process self-information.readsafe
controller.app.instance.getRead one live application instance by ID.readsafe
controller.app.instance.removeRemove one application instance from the live registry.host_configurationnon-idempotent
controller.app.instance.reportCreate or refresh one leased application-instance report.host_configurationnon-idempotent
controller.app.instancesList live application instances and their bounded non-secret state.readsafe
controller.app.launchEnsure, launch, or focus a named TUI or WebUI surface without arbitrary process execution.host_configurationidempotent-with-authoritative-readback
controller.app.navigateNavigate all, one surface, or one exact live application instance.host_configurationnon-idempotent
controller.app.navigation.commitCommit one follower group's canonical page and return its correlated coordinator outcome.host_configurationnon-idempotent
controller.app.pageapp pagehost_configurationnon-idempotent
controller.artifact.captureartifact captureprogrammingnon-idempotent
controller.artifact.fetchartifact fetchprogrammingnon-idempotent
controller.artifact.listartifact listreadsafe
controller.artifact.manifestartifact manifestreadsafe
controller.artifact.uploadartifact uploadprogrammingidempotent-with-authoritative-readback
controller.artifact.upload.abortAbort and remove an incomplete peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.beginBegin a bounded correlated peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.chunkAppend one ordered bounded chunk to a peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.finishRevalidate and publish a completed peer artifact transfer.programmingnon-idempotent
controller.audio.cuesReturn the receiving host's declared local sound-effect intent vocabulary.readsafe
controller.audio.playPlay one validated sound-effect intent on the receiving host while honoring its mute and volume policy.integrationsnon-idempotent
controller.board_automation.clearClear the board automation table and verify the empty snapshot.board_commandsidempotent-with-authoritative-readback
controller.board_automation.listRead one generation-consistent board automation snapshot.readsafe
controller.board_automation.putTransactionally add or edit one board automation and verify readback.board_commandsnon-idempotent
controller.board_automation.removeRemove one board automation and verify its absence.board_commandsidempotent-with-authoritative-readback
controller.board_profile.getReturn the attached board identity, configured wiring mode, and opaque profile revision.readsafe
controller.board_profile.updateBind the attached physical board identity to one explicit host-owned wiring profile.host_configurationidempotent-with-authoritative-readback
controller.board.firmware.downloadQueue a fresh verified flash-only readback from the board.programmingnon-idempotent
controller.board.firmware.uploadQueue a guarded transfer of a staged immutable firmware artifact to the board.programmingnon-idempotent
controller.bridge.callbridge callbridge_callsnon-idempotent
controller.bridge.listbridge listreadsafe
controller.closecloseconnection_controlidempotent-with-authoritative-readback
controller.command.catalogReturn the machine-readable shared command catalog.readsafe
controller.command.executeRun a shared command after semantic capability classification.dynamicnon-idempotent
controller.connectconnectconnection_controlidempotent-with-authoritative-readback
controller.device.actiondevice actionintegrationsnon-idempotent
controller.device.inspectdevice inspectintegrationsidempotent-with-authoritative-readback
controller.device.statusdevice statusintegrationsidempotent-with-authoritative-readback
controller.discovery.checkdiscovery checkreadsafe
controller.discovery.configReturn persistent network advertisement configuration.readsafe
controller.discovery.config.getReturn persistent network advertisement configuration.readsafe
controller.discovery.config.setPersist and hot-apply network advertisement configuration.integrationsidempotent-with-authoritative-readback
controller.discovery.connectVerify an authenticated discovered host and return its health and snapshot.bridge_callsidempotent-with-authoritative-readback
controller.discovery.github.releasediscovery github releasereadsafe
controller.discovery.github.workflowdiscovery github workflowreadsafe
controller.discovery.local_manifestdiscovery local manifestreadsafe
controller.discovery.manifestdiscovery manifestreadsafe
controller.discovery.scandiscovery scanreadsafe
controller.discovery.stagediscovery stageprogrammingnon-idempotent
controller.discovery.statusdiscovery statusreadsafe
controller.display.senddisplay sendboard_commandsnon-idempotent
controller.estop.getReturn the authoritative PCController-wide effect and motion interlock state.readsafe
controller.estop.setEngage or release the PCController-wide effect and motion interlock; release never resumes cancelled work.board_commandsidempotent-with-authoritative-readback
controller.event.latestevent latesteventssafe
controller.event.nextLong-poll the next retained event after an event ID, optionally selecting activity, state, telemetry, or debug.eventssafe
controller.exitexitshutdownidempotent-with-authoritative-readback
controller.firmware.buildCompile the canonical host project with reviewed feature names and publish correlated ordered program progress events.programmingnon-idempotent
controller.front_panelfront panelreadsafe
controller.front-panelfront panelreadsafe
controller.history.statushistory statusreadsafe
controller.history.timelinehistory timelinereadsafe
controller.host_menu.confighost menu configreadsafe
controller.host_menu.config.gethost menu config getreadsafe
controller.host_menu.config.sethost menu config sethost_configurationidempotent-with-authoritative-readback
controller.host_menu.configurehost menu configurehost_configurationidempotent-with-authoritative-readback
controller.host_menu.content.pushhost menu content pushboard_commandsidempotent-with-authoritative-readback
controller.host_menu.directory.replacehost menu directory replaceboard_commandsidempotent-with-authoritative-readback
controller.host_menu.statehost menu statereadsafe
controller.host.factshost factsreadsafe
controller.host.facts.cataloghost facts catalogreadsafe
controller.hotkeys.gethotkeys getreadsafe
controller.hotkeys.sethotkeys sethost_configurationidempotent-with-authoritative-readback
controller.illumination.getRead persisted enclosure-light policy, live door-selected target, and exact applied MOSFET channel-12 PWM.readsafe
controller.illumination.overrideTemporarily apply exact 0..4095 PWM to MOSFET channel 12 without changing or taking ownership from the enclosure-light policy, then return authoritative readback.board_commandsidempotent-with-authoritative-readback
controller.illumination.setChange only mode/on/off illumination fields, verify EEPROM durability, and return authoritative live state.board_commandsidempotent-with-authoritative-readback
controller.integrations.local.getintegrations local getintegrationsidempotent-with-authoritative-readback
controller.integrations.local.setintegrations local setintegrationsidempotent-with-authoritative-readback
controller.integrations.statusReturn requested and effective buzzer routing and playback state.readsafe
controller.lcd.presentation.configurelcd presentation configurehost_configurationidempotent-with-authoritative-readback
controller.lcd.presentation.statuslcd presentation statusreadsafe
controller.lcd.prioritylcd priorityboard_commandsnon-idempotent
controller.lcd.promptlcd promptboard_commandsnon-idempotent
controller.media.authority.changeRequest, accept, reject, release, lock, or unlock playback authority for a registered client.board_commandsidempotent-with-authoritative-readback
controller.media.authority.getReturn the registered playback authority owner, pending requests, exclusivity, and revision.readsafe
controller.media.playback.getReturn the authoritative registered consumer playback clock and prepared-timeline state.readsafe
controller.media.playback.updatePublish one sequenced playback-clock update from the registered authority owner.board_commandsidempotent-with-authoritative-readback
controller.media.timeline.getReturn the prepared media timeline, acknowledgement ledger, and sticky failure state.readsafe
controller.media.timeline.prepareValidate and arm a revisioned host-executed hardware timeline for a registered paused consumer.board_commandsidempotent-with-authoritative-readback
controller.melodies.listReturn the effective configured host melody catalog.readsafe
controller.menu.currentmenu currentreadsafe
controller.menu.jumpmenu jumpboard_commandsidempotent-with-authoritative-readback
controller.menu.layout.getmenu layout getreadsafe
controller.menu.layout.setmenu layout setboard_commandsidempotent-with-authoritative-readback
controller.menu.listmenu listreadsafe
controller.menu.pagemenu pageboard_commandsidempotent-with-authoritative-readback
controller.message.sendmessage sendmessagesnon-idempotent
controller.mirror.applymirror applyboard_commandsidempotent-with-authoritative-readback
controller.mirror.configuremirror configurehost_configurationidempotent-with-authoritative-readback
controller.mirror.inspectmirror inspectreadsafe
controller.mirror.resyncmirror resyncboard_commandsidempotent-with-authoritative-readback
controller.mirror.statusmirror statusreadsafe
controller.network.peers.getReturn persistent peer topology with secret references and no plaintext credentials.readsafe
controller.network.peers.setReplace and hot-apply peer topology; events, state, and status topics are accepted, and only optional secret references may carry compatibility credentials.host_configurationidempotent-with-authoritative-readback
controller.opcode.exchangeExchange an opaque versionless UART opcode and caller-selected response opcode.board_commandsnon-idempotent
controller.opcode.requestAlias for an opaque versionless UART opcode exchange.board_commandsnon-idempotent
controller.opcode.sendSend an opaque versionless UART opcode; ACK is expected by default.board_commandsnon-idempotent
controller.openopenconnection_controlidempotent-with-authoritative-readback
controller.os.configureos configurehost_configurationidempotent-with-authoritative-readback
controller.os.factsos factsreadsafe
controller.os.facts.catalogos facts catalogreadsafe
controller.os.keyos keyvirtual_keysnon-idempotent
controller.os.policyos policyreadsafe
controller.os.poweros powerpower_actionsnon-idempotent
controller.os.statusos statusreadsafe
controller.peer.update.hostTransfer a verified executable using a caller-generated idempotency_key, wait through the peer restart, and verify its exact active SHA-256 acknowledgement. Retry uncertain outcomes with the same key.programmingnon-idempotent
controller.peripheral.folder.updateAtomically create, rename, set the icon, delete or move members of a board-profile channel folder using required expected_revision; return the complete authoritative folder/control catalog.host_configurationidempotent-with-authoritative-readback
controller.peripheral.presentation.updateUpdate one advertised peripheral name, icon, group, kind-local order, visibility, or lock using optional optimistic concurrency.host_configurationidempotent-with-authoritative-readback
controller.peripheralsperipheralsreadsafe
controller.peripherals.getperipherals getreadsafe
controller.peripherals.setperipherals sethost_configurationidempotent-with-authoritative-readback
controller.pingReturn service health and protocol identity.readsafe
controller.port.closeport closeconnection_controlidempotent-with-authoritative-readback
controller.port.openport openconnection_controlidempotent-with-authoritative-readback
controller.port.ownerport ownerreadsafe
controller.port.processport processreadsafe
controller.port.resetport resetresetnon-idempotent
controller.portsportsreadsafe
controller.program_state.getprogram state getreadsafe
controller.program_state.setprogram state setboard_commandsidempotent-with-authoritative-readback
controller.program-state.getprogram state getreadsafe
controller.program-state.setprogram state setboard_commandsidempotent-with-authoritative-readback
controller.pwm.channel.configurePersist one PWM channel's output type, icon, transfer curve, and gamma, then publish the changed descriptor.host_configurationidempotent-with-authoritative-readback
controller.pwm.offpwm offboard_commandsidempotent-with-authoritative-readback
controller.pwm.setpwm setboard_commandsidempotent-with-authoritative-readback
controller.pwm.valuespwm valuesreadsafe
controller.quitquitshutdownidempotent-with-authoritative-readback
controller.resetresetresetnon-idempotent
controller.reset.linesreset linesresetnon-idempotent
controller.restore.flashRestore a captured flash backup through the guarded restore path.programmingnon-idempotent
controller.rf.binding.putTransactionally create or replace one validated host-owned RF application binding.host_configurationidempotent-with-authoritative-readback
controller.rf.binding.removeRemove one named host-owned RF application binding.host_configurationidempotent-with-authoritative-readback
controller.rf.catalogReturn learned RF records, live capture state, persisted bindings, and currently advertised action choices.readsafe
controller.rf.clearrf clearboard_commandsidempotent-with-authoritative-readback
controller.rf.learn.cancelrf learn cancelboard_commandsidempotent-with-authoritative-readback
controller.rf.learn.startrf learn startboard_commandsnon-idempotent
controller.rf.learn.statusrf learn statusreadsafe
controller.rf.listrf listreadsafe
controller.rf.mapReplace one learned RF mapping and return board readback.board_commandsidempotent-with-authoritative-readback
controller.rf.presentationrf presentationreadsafe
controller.rf.removerf removeboard_commandsidempotent-with-authoritative-readback
controller.rf.transmitTransmit one validated RF waveform request.board_commandsnon-idempotent
controller.session.snapshotsession snapshotreadsafe
controller.session.snapshot.lastsession snapshot lastreadsafe
controller.snapshotReturn the authoritative cached controller snapshot.readsafe
controller.statusstatusreadsafe
controller.status_led.releasestatus led releaseboard_commandsidempotent-with-authoritative-readback
controller.status_led.setstatus led setboard_commandsidempotent-with-authoritative-readback
controller.subscribeSubscribe this WebSocket connection to activity events, changed state, explicit debug, raw opcodes, and/or status.eventssafe
controller.system.statussystem statusreadsafe
controller.temperaturestemperaturesreadsafe
controller.ui.configReturn host-owned UI settings, including status_interval_ms and measurement_freshness_ms.readsafe
controller.ui.config.getReturn host-owned UI settings, including status_interval_ms and measurement_freshness_ms.readsafe
controller.ui.config.setAtomically persist host UI fields; status_interval_ms is 50..60000 and measurement_freshness_ms is refresh+100..120000.host_configurationidempotent-with-authoritative-readback
controller.unsubscribeRemove this WebSocket connection's active subscriptions.eventssafe
controller.update.eepromupdate eepromprogrammingnon-idempotent
controller.update.firmwareupdate firmwareprogrammingnon-idempotent
controller.update.hostupdate hostprogrammingnon-idempotent
controller.update.statusupdate statusreadsafe
controller.virtual_keyvirtual keyvirtual_keysidempotent-with-authoritative-readback
controller.webhooks.clearClear explicitly selected dead-letter deliveries.integrationsidempotent-with-authoritative-readback
controller.webhooks.deadList bounded non-secret dead-letter deliveries.readsafe
controller.webhooks.pendingList bounded non-secret pending outbound deliveries.readsafe
controller.webhooks.replayReplay explicitly selected dead-letter deliveries.integrationsnon-idempotent
controller.webhooks.statusReturn bounded outbound queue and dead-letter counters.readsafe
-

Contract digest 5a62b5c7926b62974aa644b824861ab263db8cd1a55bf60eda834aef2dee4682. Generated by Tools/Audit/generate-api-reference.mjs.

+
OFFLINE CONTRACT · LIVING API

PCController API

The primary host exposes one unversioned, safety-gated living surface across REST, JSON-RPC, WebSocket, and the bounded Socket.IO adapter. Loopback is the default; immediate-alpha application authentication and authorization are disabled under issue #148.

OpenAPI 3.1AsyncAPI 3.0JSON-RPC schema184 RPC methods88 HTTP operations
+

HTTP operations

Canonical routes live directly under /api/. JSON bodies are capped at 1 MiB.

MethodPathPurposeCapability
GET/healthzService liveness and API identitypublic
GET/upnp/public.jsonBounded public host, board, endpoint, health, and telemetry directorypublic
GET/api/ui-configNon-secret browser bootstrappublic
GET/api/auth/server-proofDormant alpha compatibility endpoint; returns 409 while application authentication is disabledpublic
POST/api/session/ticketDormant alpha compatibility endpoint; returns 409 while application authentication is disabledsession
POST/api/rpcJSON-RPC 2.0 requestdynamic
GET/api/snapshotAuthoritative cached controller snapshotread
POST/api/diagnostics/cpu-profileExplicit five-second application CPU profile; loopback callers only, one active capture, no board actions or request bodylocal-only
GET/api/estopAuthoritative effect and motion interlock stateread
PUT/api/estopEngage or release the effect and motion interlockboard_commands
POST/api/estopEngage or release the effect and motion interlockboard_commands
GET/api/peripheralsPeripheral descriptors and host-owned namesread
PUT/api/peripheralsReplace host-owned peripheral nameshost_configuration
GET/api/pwmAuthoritative PWM stateread
PUT/api/pwmMutate PWM state with board readbackboard_commands
DELETE/api/pwmMutate PWM state with board readbackboard_commands
GET/api/commandsShared command catalogread
GET/api/program-stateHost-owned program stateread
PUT/api/program-stateUpdate host-owned program stateboard_commands
POST/api/program-stateUpdate host-owned program stateboard_commands
GET/api/menu/catalogLive board menu catalogread
GET/api/menu/layoutLive board menu layoutread
PUT/api/menu/layoutPersist a validated board menu layoutboard_commands
POST/api/menu/layoutPersist a validated board menu layoutboard_commands
GET/api/host-menusHost-presented menu directoryread
PUT/api/host-menusReplace the host-presented menu directoryhost_configuration
POST/api/host-menusReplace the host-presented menu directoryhost_configuration
GET/api/os/statusHost status and policyread
GET/api/os/factsBounded read-only host factsread
POST/api/os/keyValidated virtual-key actionvirtual_keys
POST/api/os/powerConfirmed system or display actionpower_actions
POST/api/commandShared command dispatcherdynamic
POST/api/messagesTyped source-tagged messagemessages
POST/api/displayPresent arbitrary seven-segment or LCD text with scroll and repeat timingboard_commands
POST/api/opcodeExchange an opaque versionless UART opcodeboard_commands
GET/api/app/bridgeOriginal coordinator bridge instance and process identityread
GET/api/app/instancesList or query live application instancesread
POST/api/app/instancesReport or remove one live application instancehost_configuration
DELETE/api/app/instancesReport or remove one live application instancehost_configuration
POST/api/app/actionFreeze and route a correlated action to exact live application instanceshost_configuration
POST/api/app/action/ackAcknowledge one exact app-action target outcomehost_configuration
GET/api/app/action/outcomeRead one bounded app-action operation outcomeread
POST/api/app/navigateNavigate all, one surface, or one exact application instancehost_configuration
POST/api/app/launchEnsure, launch, or focus a named TUI or WebUI surfacehost_configuration
GET/api/bridgesConfigured bridge stateread
POST/api/bridges/callCorrelated bridge callbridge_calls
GET/api/artifacts/manifestArtifact/default/current manifestread
GET/api/artifactsContent-addressed artifact catalogread
POST/api/artifacts/uploadImport a verified artifactprogramming
POST/api/artifacts/fetchQueue a bounded remote artifact fetchprogramming
POST/api/artifacts/captureCapture flash or EEPROM explicitlyprogramming
GET/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
HEAD/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
GET/api/artifacts/current/{kind}Latest explicitly captured artifactread
HEAD/api/artifacts/current/{kind}Latest explicitly captured artifactread
POST/api/board/firmware/uploadQueue a guarded staged-firmware transfer to the boardprogramming
GET/api/board/firmware/downloadDownload the latest verified board flash readbackread
HEAD/api/board/firmware/downloadDownload the latest verified board flash readbackread
POST/api/board/firmware/downloadQueue a fresh verified board flash readbackprogramming
POST/api/updates/{kind}Queue an explicitly authorized updateprogramming
POST/api/restores/flashQueue an explicitly authorized flash restoreprogramming
GET/api/updates/status/{id}Update operation progressread
POST/api/discovery/github/workflowDiscover workflow artifactsread
POST/api/discovery/github/releaseDiscover release assetsread
GET/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/checkCompare candidate and installed identitiesread
POST/api/discovery/stageStage a verified candidate without programmingprogramming
GET/api/discovery/status/{id}Discovery or staging progressread
POST/api/webhooks/inboundOptional inbound typed webhookmessages
GET/api/webhooks/outbound/statusOutbound delivery queue and dead-letter statusread
GET/api/webhooks/outbound/pendingBounded non-secret pending delivery listread
GET/api/webhooks/outbound/deadBounded non-secret dead-letter listread
POST/api/webhooks/outbound/replayReplay explicitly selected dead-letter deliveriesintegrations
POST/api/webhooks/outbound/clearClear explicitly selected dead-letter deliveriesintegrations
GET/api/integrations/statusRequested and effective buzzer routing and playback stateread
GET/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
HEAD/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
POST/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PUT/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PATCH/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
DELETE/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
GET/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
HEAD/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
POST/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PUT/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PATCH/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
DELETE/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
+

JSON-RPC methods

Standard JSON-RPC errors are preserved; host extensions use -32001 for authentication, -32003 for capability denial, and -32000 for runtime or device failures.

MethodPurposeCapabilityIdempotency
controller.action.invokeInvoke one stable action ID advertised by the attached board profile and wait for the board acknowledgement.board_commandsnon-idempotent
controller.app.actionFreeze a validated action to exact live targets and return its correlated per-target delivery operation.dynamicnon-idempotent
controller.app.action.ackAcknowledge one exact app-action target as applied or rejected.host_configurationidempotent-with-authoritative-readback
controller.app.action.outcomeReturn one bounded app-action operation and its exact per-target outcomes.readsafe
controller.app.bridgeReturn the original coordinator bridge instance and its bounded process self-information.readsafe
controller.app.instance.getRead one live application instance by ID.readsafe
controller.app.instance.removeRemove one application instance from the live registry.host_configurationnon-idempotent
controller.app.instance.reportCreate or refresh one leased application-instance report.host_configurationnon-idempotent
controller.app.instancesList live application instances and their bounded non-secret state.readsafe
controller.app.launchEnsure, launch, or focus a named TUI or WebUI surface without arbitrary process execution.host_configurationidempotent-with-authoritative-readback
controller.app.navigateNavigate all, one surface, or one exact live application instance.host_configurationnon-idempotent
controller.app.navigation.commitCommit one follower group's canonical page and return its correlated coordinator outcome.host_configurationnon-idempotent
controller.app.pageapp pagehost_configurationnon-idempotent
controller.artifact.captureartifact captureprogrammingnon-idempotent
controller.artifact.fetchartifact fetchprogrammingnon-idempotent
controller.artifact.listartifact listreadsafe
controller.artifact.manifestartifact manifestreadsafe
controller.artifact.uploadartifact uploadprogrammingidempotent-with-authoritative-readback
controller.artifact.upload.abortAbort and remove an incomplete peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.beginBegin a bounded correlated peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.chunkAppend one ordered bounded chunk to a peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.finishRevalidate and publish a completed peer artifact transfer.programmingnon-idempotent
controller.audio.cuesReturn the receiving host's declared local sound-effect intent vocabulary.readsafe
controller.audio.playPlay one validated sound-effect intent on the receiving host while honoring its mute and volume policy.integrationsnon-idempotent
controller.board_automation.clearClear the board automation table and verify the empty snapshot.board_commandsidempotent-with-authoritative-readback
controller.board_automation.listRead one generation-consistent board automation snapshot.readsafe
controller.board_automation.putTransactionally add or edit one board automation and verify readback.board_commandsnon-idempotent
controller.board_automation.removeRemove one board automation and verify its absence.board_commandsidempotent-with-authoritative-readback
controller.board_profile.getReturn the attached board identity, configured wiring mode, and opaque profile revision.readsafe
controller.board_profile.updateBind the attached physical board identity to one explicit host-owned wiring profile.host_configurationidempotent-with-authoritative-readback
controller.board.firmware.downloadQueue a fresh verified flash-only readback from the board.programmingnon-idempotent
controller.board.firmware.uploadQueue a guarded transfer of a staged immutable firmware artifact to the board.programmingnon-idempotent
controller.bridge.callbridge callbridge_callsnon-idempotent
controller.bridge.listbridge listreadsafe
controller.closecloseconnection_controlidempotent-with-authoritative-readback
controller.command.catalogReturn the machine-readable shared command catalog.readsafe
controller.command.executeRun a shared command after semantic capability classification.dynamicnon-idempotent
controller.connectconnectconnection_controlidempotent-with-authoritative-readback
controller.device.actiondevice actionintegrationsnon-idempotent
controller.device.inspectdevice inspectintegrationsidempotent-with-authoritative-readback
controller.device.statusdevice statusintegrationsidempotent-with-authoritative-readback
controller.discovery.checkdiscovery checkreadsafe
controller.discovery.configReturn persistent network advertisement configuration.readsafe
controller.discovery.config.getReturn persistent network advertisement configuration.readsafe
controller.discovery.config.setPersist and hot-apply network advertisement configuration.integrationsidempotent-with-authoritative-readback
controller.discovery.connectVerify an authenticated discovered host and return its health and snapshot.bridge_callsidempotent-with-authoritative-readback
controller.discovery.github.releasediscovery github releasereadsafe
controller.discovery.github.workflowdiscovery github workflowreadsafe
controller.discovery.local_manifestdiscovery local manifestreadsafe
controller.discovery.manifestdiscovery manifestreadsafe
controller.discovery.scandiscovery scanreadsafe
controller.discovery.stagediscovery stageprogrammingnon-idempotent
controller.discovery.statusdiscovery statusreadsafe
controller.display.senddisplay sendboard_commandsnon-idempotent
controller.estop.getReturn the authoritative PCController-wide effect and motion interlock state.readsafe
controller.estop.setEngage or release the PCController-wide effect and motion interlock; release never resumes cancelled work.board_commandsidempotent-with-authoritative-readback
controller.event.latestevent latesteventssafe
controller.event.nextLong-poll the next retained event after an event ID, optionally selecting activity, state, telemetry, or debug.eventssafe
controller.exitexitshutdownidempotent-with-authoritative-readback
controller.firmware.buildCompile the canonical host project with reviewed feature names and publish correlated ordered program progress events.programmingnon-idempotent
controller.front_panelfront panelreadsafe
controller.front-panelfront panelreadsafe
controller.history.statushistory statusreadsafe
controller.history.timelinehistory timelinereadsafe
controller.host_menu.confighost menu configreadsafe
controller.host_menu.config.gethost menu config getreadsafe
controller.host_menu.config.sethost menu config sethost_configurationidempotent-with-authoritative-readback
controller.host_menu.configurehost menu configurehost_configurationidempotent-with-authoritative-readback
controller.host_menu.content.pushhost menu content pushboard_commandsidempotent-with-authoritative-readback
controller.host_menu.directory.replacehost menu directory replaceboard_commandsidempotent-with-authoritative-readback
controller.host_menu.statehost menu statereadsafe
controller.host.factshost factsreadsafe
controller.host.facts.cataloghost facts catalogreadsafe
controller.hotkeys.gethotkeys getreadsafe
controller.hotkeys.sethotkeys sethost_configurationidempotent-with-authoritative-readback
controller.illumination.getRead persisted enclosure-light policy, live door-selected target, and exact applied MOSFET channel-12 PWM.readsafe
controller.illumination.overrideTemporarily apply exact 0..4095 PWM to MOSFET channel 12 without changing or taking ownership from the enclosure-light policy, then return authoritative readback.board_commandsidempotent-with-authoritative-readback
controller.illumination.setChange only mode/on/off illumination fields, verify EEPROM durability, and return authoritative live state.board_commandsidempotent-with-authoritative-readback
controller.integrations.local.getintegrations local getintegrationsidempotent-with-authoritative-readback
controller.integrations.local.setintegrations local setintegrationsidempotent-with-authoritative-readback
controller.integrations.statusReturn requested and effective buzzer routing and playback state.readsafe
controller.lcd.presentation.configurelcd presentation configurehost_configurationidempotent-with-authoritative-readback
controller.lcd.presentation.statuslcd presentation statusreadsafe
controller.lcd.prioritylcd priorityboard_commandsnon-idempotent
controller.lcd.promptlcd promptboard_commandsnon-idempotent
controller.media.authority.changeRequest, accept, reject, release, lock, or unlock playback authority for a registered client.board_commandsidempotent-with-authoritative-readback
controller.media.authority.getReturn the registered playback authority owner, pending requests, exclusivity, and revision.readsafe
controller.media.playback.getReturn the authoritative registered consumer playback clock and prepared-timeline state.readsafe
controller.media.playback.updatePublish one sequenced playback-clock update from the registered authority owner.board_commandsidempotent-with-authoritative-readback
controller.media.timeline.getReturn the prepared media timeline, acknowledgement ledger, and sticky failure state.readsafe
controller.media.timeline.prepareValidate and arm a revisioned host-executed hardware timeline for a registered paused consumer.board_commandsidempotent-with-authoritative-readback
controller.melodies.listReturn the effective configured host melody catalog.readsafe
controller.menu.currentmenu currentreadsafe
controller.menu.jumpmenu jumpboard_commandsidempotent-with-authoritative-readback
controller.menu.layout.getmenu layout getreadsafe
controller.menu.layout.setmenu layout setboard_commandsidempotent-with-authoritative-readback
controller.menu.listmenu listreadsafe
controller.menu.pagemenu pageboard_commandsidempotent-with-authoritative-readback
controller.message.sendmessage sendmessagesnon-idempotent
controller.mirror.applymirror applyboard_commandsidempotent-with-authoritative-readback
controller.mirror.configuremirror configurehost_configurationidempotent-with-authoritative-readback
controller.mirror.inspectmirror inspectreadsafe
controller.mirror.resyncmirror resyncboard_commandsidempotent-with-authoritative-readback
controller.mirror.statusmirror statusreadsafe
controller.network.peers.getReturn persistent peer topology with secret references and no plaintext credentials.readsafe
controller.network.peers.setReplace and hot-apply peer topology; events, state, and status topics are accepted, and only optional secret references may carry compatibility credentials.host_configurationidempotent-with-authoritative-readback
controller.opcode.exchangeExchange an opaque versionless UART opcode and caller-selected response opcode.board_commandsnon-idempotent
controller.opcode.requestAlias for an opaque versionless UART opcode exchange.board_commandsnon-idempotent
controller.opcode.sendSend an opaque versionless UART opcode; ACK is expected by default.board_commandsnon-idempotent
controller.openopenconnection_controlidempotent-with-authoritative-readback
controller.os.configureos configurehost_configurationidempotent-with-authoritative-readback
controller.os.factsos factsreadsafe
controller.os.facts.catalogos facts catalogreadsafe
controller.os.keyos keyvirtual_keysnon-idempotent
controller.os.policyos policyreadsafe
controller.os.poweros powerpower_actionsnon-idempotent
controller.os.statusos statusreadsafe
controller.peer.update.hostTransfer a verified executable using a caller-generated idempotency_key, wait through the peer restart, and verify its exact active SHA-256 acknowledgement. Retry uncertain outcomes with the same key.programmingnon-idempotent
controller.peripheral.folder.updateAtomically create, rename, set the icon, delete or move members of a board-profile channel folder using required expected_revision; return the complete authoritative folder/control catalog.host_configurationidempotent-with-authoritative-readback
controller.peripheral.presentation.updateUpdate one advertised peripheral name, icon, group, kind-local order, visibility, or lock using optional optimistic concurrency.host_configurationidempotent-with-authoritative-readback
controller.peripheralsperipheralsreadsafe
controller.peripherals.getperipherals getreadsafe
controller.peripherals.setperipherals sethost_configurationidempotent-with-authoritative-readback
controller.pingReturn service health and protocol identity.readsafe
controller.port.closeport closeconnection_controlidempotent-with-authoritative-readback
controller.port.openport openconnection_controlidempotent-with-authoritative-readback
controller.port.ownerport ownerreadsafe
controller.port.processport processreadsafe
controller.port.resetport resetresetnon-idempotent
controller.portsportsreadsafe
controller.program_state.getprogram state getreadsafe
controller.program_state.setprogram state setboard_commandsidempotent-with-authoritative-readback
controller.program-state.getprogram state getreadsafe
controller.program-state.setprogram state setboard_commandsidempotent-with-authoritative-readback
controller.pwm.channel.configurePersist one PWM channel's output type, icon, transfer curve, and gamma, then publish the changed descriptor.host_configurationidempotent-with-authoritative-readback
controller.pwm.offpwm offboard_commandsidempotent-with-authoritative-readback
controller.pwm.setpwm setboard_commandsidempotent-with-authoritative-readback
controller.pwm.valuespwm valuesreadsafe
controller.quitquitshutdownidempotent-with-authoritative-readback
controller.resetresetresetnon-idempotent
controller.reset.linesreset linesresetnon-idempotent
controller.restore.flashRestore a captured flash backup through the guarded restore path.programmingnon-idempotent
controller.rf.binding.putTransactionally create or replace one validated host-owned RF application binding.host_configurationidempotent-with-authoritative-readback
controller.rf.binding.removeRemove one named host-owned RF application binding.host_configurationidempotent-with-authoritative-readback
controller.rf.catalogReturn learned RF records, live capture state, persisted bindings, and currently advertised action choices.readsafe
controller.rf.clearrf clearboard_commandsidempotent-with-authoritative-readback
controller.rf.learn.cancelrf learn cancelboard_commandsidempotent-with-authoritative-readback
controller.rf.learn.startrf learn startboard_commandsnon-idempotent
controller.rf.learn.statusrf learn statusreadsafe
controller.rf.listrf listreadsafe
controller.rf.mapReplace one learned RF mapping and return board readback.board_commandsidempotent-with-authoritative-readback
controller.rf.presentationrf presentationreadsafe
controller.rf.removerf removeboard_commandsidempotent-with-authoritative-readback
controller.rf.transmitTransmit one validated RF waveform request.board_commandsnon-idempotent
controller.session.snapshotsession snapshotreadsafe
controller.session.snapshot.lastsession snapshot lastreadsafe
controller.snapshotReturn the authoritative cached controller snapshot.readsafe
controller.statusstatusreadsafe
controller.status_led.releasestatus led releaseboard_commandsidempotent-with-authoritative-readback
controller.status_led.setstatus led setboard_commandsidempotent-with-authoritative-readback
controller.subscribeSubscribe this WebSocket connection to activity events, changed state, explicit debug, raw opcodes, and/or status.eventssafe
controller.system.statussystem statusreadsafe
controller.temperaturestemperaturesreadsafe
controller.udp.calludp callbridge_callsidempotent-with-authoritative-readback
controller.udp.config.getudp config getreadsafe
controller.udp.config.setudp config sethost_configurationidempotent-with-authoritative-readback
controller.udp.events.getudp events getreadsafe
controller.udp.statusudp statusreadsafe
controller.udp.subscribeudp subscribehost_configurationidempotent-with-authoritative-readback
controller.udp.unsubscribeudp unsubscribehost_configurationidempotent-with-authoritative-readback
controller.ui.configReturn host-owned UI settings, including status_interval_ms and measurement_freshness_ms.readsafe
controller.ui.config.getReturn host-owned UI settings, including status_interval_ms and measurement_freshness_ms.readsafe
controller.ui.config.setAtomically persist host UI fields; status_interval_ms is 50..60000 and measurement_freshness_ms is refresh+100..120000.host_configurationidempotent-with-authoritative-readback
controller.unsubscribeRemove this WebSocket connection's active subscriptions.eventssafe
controller.update.eepromupdate eepromprogrammingnon-idempotent
controller.update.firmwareupdate firmwareprogrammingnon-idempotent
controller.update.hostupdate hostprogrammingnon-idempotent
controller.update.statusupdate statusreadsafe
controller.virtual_keyvirtual keyvirtual_keysidempotent-with-authoritative-readback
controller.webhooks.clearClear explicitly selected dead-letter deliveries.integrationsidempotent-with-authoritative-readback
controller.webhooks.deadList bounded non-secret dead-letter deliveries.readsafe
controller.webhooks.pendingList bounded non-secret pending outbound deliveries.readsafe
controller.webhooks.replayReplay explicitly selected dead-letter deliveries.integrationsnon-idempotent
controller.webhooks.statusReturn bounded outbound queue and dead-letter counters.readsafe
+

Contract digest 2a2f16b5d1a53f8890cb79827a7d652f33521c99f639eb5be0bb4edfc7004f96. Generated by Tools/Audit/generate-api-reference.mjs.

diff --git a/Tools/Controller/cmd/controller/main.go b/Tools/Controller/cmd/controller/main.go index 4f3d68cba..2bb4661f1 100644 --- a/Tools/Controller/cmd/controller/main.go +++ b/Tools/Controller/cmd/controller/main.go @@ -50,6 +50,7 @@ var ( ) func main() { + prepareStartupPresentation(os.Args[1:], os.Stdout) if _, err := envfile.LoadProcess(); err != nil { fmt.Fprintln(os.Stderr, "environment:", err) os.Exit(1) @@ -556,12 +557,24 @@ func runWebWithContext( noAuto := flags.Bool("no-auto", false, "start with automatic connection paused") noOpen := flags.Bool("no-open", false, "serve the web app without opening a browser") noTray := flags.Bool("no-tray", false, "serve the web app without a native system-tray menu") + noTUI := flags.Bool("no-tui", false, "disable the attached TUI and hide this host's owned console window") + consoleOptions, err := addTUIConsoleFlags(flags, store.Current().UI.TUIConsole) + if err != nil { + return err + } listen := flags.String("listen", "", "transient loopback IPC/WebUI listen address (does not change saved config)") if err := flags.Parse(args); err != nil { return err } if flags.NArg() != 0 { - return errors.New("usage: controller web [--listen 127.0.0.1:8787] [--no-open] [--no-tray] [--no-auto] [connection flags]") + return errors.New("usage: controller web [--listen 127.0.0.1:8787] [--no-open] [--no-tray] [--no-tui] [--no-auto] [connection flags]") + } + if err := consoleOptions.captureOverrides(flags); err != nil { + return err + } + tuiEnabled := !*noTUI && externalContext == nil && terminalOutput(stdout) && terminalOutput(os.Stdin) + if !tuiEnabled { + _, _ = consolewindow.HideBackground(true) } connection.captureOverrides(flags) if err := buzzerOptions.captureOverrides(flags); err != nil { @@ -610,6 +623,10 @@ func runWebWithContext( return err } } + if tuiEnabled { + configured := currentPrimaryEndpoint() + return runRemoteTUIContext(context.Background(), existing.Listen, configured.AuthToken, stdout, store, consoleOptions, false) + } if *noOpen { return nil } @@ -799,11 +816,23 @@ func runWebWithContext( } else if !*noOpen { fmt.Fprintln(stdout, "controller offline; browser will open after an authenticated connection") } + var tuiDone <-chan error + if tuiEnabled { + done := make(chan error, 1) + tuiDone = done + configured := currentPrimaryEndpoint() + go func() { + done <- runRemoteTUIContext(ctx, configured.Listen, configured.AuthToken, stdout, store, consoleOptions, false) + }() + } select { case <-ctx.Done(): return nil case <-primary.QuitRequested(): return nil + case err := <-tuiDone: + // An exited or failed terminal never leaves a blank visible web host. + return err } } 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/cmd/controller/remote_tui.go b/Tools/Controller/cmd/controller/remote_tui.go index 0ef19369c..6951e5278 100644 --- a/Tools/Controller/cmd/controller/remote_tui.go +++ b/Tools/Controller/cmd/controller/remote_tui.go @@ -88,6 +88,10 @@ type remotePortWire struct { } type remoteSnapshotWire struct { + ConnectionGeneration uint64 `json:"connection_generation"` + PWMValues *native.PWMValues `json:"pwm_values"` + PWMRevision uint64 `json:"pwm_revision"` + PWMUpdated time.Time `json:"pwm_updated"` HostInstanceID string `json:"host_instance_id,omitempty"` Connected bool `json:"connected"` Paused bool `json:"paused"` @@ -357,39 +361,45 @@ func (client *remoteTUIIPC) Snapshot(ctx context.Context) (control.Snapshot, err } epoch := client.adoptLiveInstanceLocked(responseInstanceID) client.liveMu.Unlock() - return control.Snapshot{ - Connected: wire.Connected, Paused: wire.Paused, - Port: ports.Info{ - Name: wire.Port.Name, IsUSB: wire.Port.VID != "" || wire.Port.PID != "", - VID: wire.Port.VID, PID: wire.Port.PID, Product: wire.Port.Product, - Manufacturer: wire.Port.Manufacturer, SerialNumber: wire.Port.SerialNumber, - FriendlyName: wire.Port.FriendlyName, InstanceID: wire.Port.InstanceID, - }, - Hello: wire.Hello, Status: wire.Status, Settings: native.Settings(wire.Settings), - HaveStatus: wire.HaveStatus, HaveSettings: wire.HaveSettings, - StatusUpdated: wire.StatusUpdated, ConnectionState: wire.ConnectionState, - ConnectionReason: wire.ConnectionReason, ConnectionUpdated: wire.ConnectionUpdated, - ConnectionPhase: wire.ConnectionPhase, ConnectionAttempt: wire.ConnectionAttempt, - ConnectionAttemptStart: wire.ConnectionAttemptStarted, - ConnectionNextRetry: wire.ConnectionNextRetry, - ConnectionRetryDelay: time.Duration(wire.ConnectionRetryDelayMS) * time.Millisecond, - ConnectionCandidate: ports.Info{ - Name: wire.ConnectionCandidate.Name, - IsUSB: wire.ConnectionCandidate.VID != "" || wire.ConnectionCandidate.PID != "", - VID: wire.ConnectionCandidate.VID, PID: wire.ConnectionCandidate.PID, - Product: wire.ConnectionCandidate.Product, - Manufacturer: wire.ConnectionCandidate.Manufacturer, - SerialNumber: wire.ConnectionCandidate.SerialNumber, - FriendlyName: wire.ConnectionCandidate.FriendlyName, - InstanceID: wire.ConnectionCandidate.InstanceID, - }, - FrontPanel: wire.FrontPanel, HaveFrontPanel: wire.HaveFrontPanel, - FrontPanelUpdated: wire.FrontPanelUpdated, StatusLED: wire.StatusLED, - HaveStatusLED: wire.HaveStatusLED, StatusLEDUpdated: wire.StatusLEDUpdated, - StatusLEDEpoch: epoch, StatusLEDRevision: wire.StatusLEDRevision, - ProgramState: wire.ProgramState, RFLearning: wire.RFLearning, Macros: wire.Macros, Effects: wire.Effects, EffectGroups: wire.EffectGroups, - HardwareProblems: wire.HardwareProblems, - }, nil + return remoteSnapshotValues(wire, epoch), nil + } +} + +func remoteSnapshotValues(wire remoteSnapshotWire, epoch uint64) control.Snapshot { + return control.Snapshot{ + ConnectionGeneration: wire.ConnectionGeneration, + PWMValues: wire.PWMValues, PWMRevision: wire.PWMRevision, PWMUpdated: wire.PWMUpdated, + Connected: wire.Connected, Paused: wire.Paused, + Port: ports.Info{ + Name: wire.Port.Name, IsUSB: wire.Port.VID != "" || wire.Port.PID != "", + VID: wire.Port.VID, PID: wire.Port.PID, Product: wire.Port.Product, + Manufacturer: wire.Port.Manufacturer, SerialNumber: wire.Port.SerialNumber, + FriendlyName: wire.Port.FriendlyName, InstanceID: wire.Port.InstanceID, + }, + Hello: wire.Hello, Status: wire.Status, Settings: native.Settings(wire.Settings), + HaveStatus: wire.HaveStatus, HaveSettings: wire.HaveSettings, + StatusUpdated: wire.StatusUpdated, ConnectionState: wire.ConnectionState, + ConnectionReason: wire.ConnectionReason, ConnectionUpdated: wire.ConnectionUpdated, + ConnectionPhase: wire.ConnectionPhase, ConnectionAttempt: wire.ConnectionAttempt, + ConnectionAttemptStart: wire.ConnectionAttemptStarted, + ConnectionNextRetry: wire.ConnectionNextRetry, + ConnectionRetryDelay: time.Duration(wire.ConnectionRetryDelayMS) * time.Millisecond, + ConnectionCandidate: ports.Info{ + Name: wire.ConnectionCandidate.Name, + IsUSB: wire.ConnectionCandidate.VID != "" || wire.ConnectionCandidate.PID != "", + VID: wire.ConnectionCandidate.VID, PID: wire.ConnectionCandidate.PID, + Product: wire.ConnectionCandidate.Product, + Manufacturer: wire.ConnectionCandidate.Manufacturer, + SerialNumber: wire.ConnectionCandidate.SerialNumber, + FriendlyName: wire.ConnectionCandidate.FriendlyName, + InstanceID: wire.ConnectionCandidate.InstanceID, + }, + FrontPanel: wire.FrontPanel, HaveFrontPanel: wire.HaveFrontPanel, + FrontPanelUpdated: wire.FrontPanelUpdated, StatusLED: wire.StatusLED, + HaveStatusLED: wire.HaveStatusLED, StatusLEDUpdated: wire.StatusLEDUpdated, + StatusLEDEpoch: epoch, StatusLEDRevision: wire.StatusLEDRevision, + ProgramState: wire.ProgramState, RFLearning: wire.RFLearning, Macros: wire.Macros, Effects: wire.Effects, EffectGroups: wire.EffectGroups, + HardwareProblems: wire.HardwareProblems, } } @@ -1097,8 +1107,11 @@ func (client *remoteTUIIPC) consumeOrBufferLiveMessage( ) (bool, error) { if !subscriptionAcknowledged { var message remoteLiveMessage - if json.Unmarshal(data, &message) == nil && message.Method == "controller.state" { + if json.Unmarshal(data, &message) == nil && (message.Method == "controller.state" || message.Method == "controller.snapshot" || message.Method == "controller.pwm.values" || message.Method == "controller.status") { if pendingStateMessages != nil { + if len(*pendingStateMessages) >= 256 { + return false, errors.New("remote subscription backlog requires a fresh baseline") + } *pendingStateMessages = append(*pendingStateMessages, append([]byte(nil), data...)) } return false, nil @@ -1140,12 +1153,47 @@ func (client *remoteTUIIPC) consumeLiveMessage(data []byte, pendingRequestID uin } now := time.Now() switch message.Method { + case "controller.snapshot": + var wire remoteSnapshotWire + if json.Unmarshal(message.Params, &wire) != nil { + return false, nil + } + client.liveMu.Lock() + matches := wire.HostInstanceID == client.liveSessionInstanceID && wire.HostInstanceID != "" + client.liveMu.Unlock() + if !matches { + return false, nil + } + snapshot := remoteSnapshotValues(wire, client.currentLiveSessionEpoch()) + client.publishLive(tui.RemoteLiveUpdate{Snapshot: &snapshot, ConnectionGeneration: wire.ConnectionGeneration}) + case "controller.pwm.values": + var values struct { + native.PWMValues + HostInstanceID string `json:"host_instance_id"` + Generation uint64 `json:"connection_generation"` + Revision uint64 `json:"revision"` + Time time.Time `json:"time"` + } + if json.Unmarshal(message.Params, &values) != nil || values.Revision == 0 { + return false, nil + } + client.liveMu.Lock() + matches := values.HostInstanceID == client.liveSessionInstanceID && values.HostInstanceID != "" + client.liveMu.Unlock() + if !matches { + return false, nil + } + client.publishLive(tui.RemoteLiveUpdate{PWMValues: &values.PWMValues, PWMRevision: values.Revision, PWMUpdated: values.Time, ConnectionGeneration: values.Generation}) case "controller.status": var update controllerapi.StatusUpdate if json.Unmarshal(message.Params, &update) != nil || update.Error != "" { return false, nil } client.liveMu.Lock() + if update.HostInstanceID != "" && update.HostInstanceID != client.liveSessionInstanceID { + client.liveMu.Unlock() + return false, nil + } identical := client.haveLastStatus && client.lastStatus == update.Status forward := !identical || now.Sub(client.lastStatusForwardedAt) >= 500*time.Millisecond if forward { @@ -1158,26 +1206,38 @@ func (client *remoteTUIIPC) consumeLiveMessage(data []byte, pendingRequestID uin return false, nil } client.publishLive(tui.RemoteLiveUpdate{ - Status: update.Status, HaveStatus: true, + ConnectionGeneration: update.ConnectionGeneration, + Status: update.Status, HaveStatus: true, StatusUpdated: update.Time, StatusReceivedAt: now, ConnectionChange: true, Connected: true, }) case "controller.state": var event controllerapi.Event - if json.Unmarshal(message.Params, &event) != nil || - event.Opcode != native.OpStatusLEDChanged { + if json.Unmarshal(message.Params, &event) != nil || event.Kind != "status_led.changed" { return false, nil } if !client.advanceLiveStateCursor(event.ID) { return false, nil } - state, err := native.ParseStatusLEDState(event.Payload) + payload := event.Payload + if len(payload) == 0 { + for _, key := range []string{"red", "green", "blue", "brightness", "effect", "condition"} { + value, err := strconv.ParseUint(event.Metadata[key], 10, 8) + if err != nil { + return false, nil + } + payload = append(payload, byte(value)) + } + } + state, err := native.ParseStatusLEDState(payload) if err != nil { return false, nil } revision, _ := strconv.ParseUint(event.Metadata["revision"], 10, 64) + generation, _ := strconv.ParseUint(event.Metadata["connection_generation"], 10, 64) client.publishLive(tui.RemoteLiveUpdate{ - StatusLED: state, HaveStatusLED: true, + ConnectionGeneration: generation, + StatusLED: state, HaveStatusLED: true, StatusLEDUpdated: event.Time, StatusLEDReceivedAt: now, StatusLEDOrderKnown: true, StatusLEDEpoch: client.currentLiveSessionEpoch(), StatusLEDRevision: revision, @@ -1232,6 +1292,22 @@ func mergeRemoteLiveUpdate(pending *tui.RemoteLiveUpdate, update tui.RemoteLiveU if pending == nil { return } + if update.ConnectionGeneration != 0 && pending.ConnectionGeneration != 0 && update.ConnectionGeneration != pending.ConnectionGeneration { + if update.ConnectionGeneration < pending.ConnectionGeneration { + return + } + *pending = tui.RemoteLiveUpdate{} + } + if update.ConnectionGeneration != 0 { + pending.ConnectionGeneration = update.ConnectionGeneration + } + if update.Snapshot != nil { + pending.Snapshot = update.Snapshot + } + if update.PWMValues != nil && (pending.PWMValues == nil || update.ConnectionGeneration > pending.ConnectionGeneration || + (update.ConnectionGeneration == pending.ConnectionGeneration && update.PWMRevision > pending.PWMRevision)) { + pending.PWMValues, pending.PWMRevision, pending.PWMUpdated, pending.ConnectionGeneration = update.PWMValues, update.PWMRevision, update.PWMUpdated, update.ConnectionGeneration + } if update.HaveStatus { pending.Status = update.Status pending.HaveStatus = true @@ -1371,6 +1447,9 @@ func runRemoteTUIContext( consoleOptions *tuiConsoleOptions, syncNavigation bool, ) error { + if terminalOutput(stdout) { + fmt.Fprintln(stdout, " Connecting terminal to controller...") + } ctx, cancel := context.WithCancel(parent) defer cancel() client := newRemoteTUIIPC(ctx, address, auth) diff --git a/Tools/Controller/cmd/controller/startup_screen.go b/Tools/Controller/cmd/controller/startup_screen.go new file mode 100644 index 000000000..48293b18c --- /dev/null +++ b/Tools/Controller/cmd/controller/startup_screen.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/mattn/go-isatty" + "pccontroller.local/controller/internal/consolewindow" + "pccontroller.local/controller/internal/productidentity" + "pccontroller.local/controller/internal/startupmode" +) + +func terminalOutput(output io.Writer) bool { + file, ok := output.(*os.File) + return ok && (isatty.IsTerminal(file.Fd()) || isatty.IsCygwinTerminal(file.Fd())) +} + +func writeTUIStartup(output io.Writer, terminal bool, title string) { + if !terminal { + return + } + fmt.Fprintf(output, "\n %s\n Starting terminal interface...\n\n", title) +} + +func prepareStartupPresentation(args []string, output io.Writer) { + if startupmode.TUI(args) { + writeTUIStartup(output, terminalOutput(output), productidentity.DefaultAppTitle()) + return + } + if startupmode.Command(args) == "web" { + explicit := startupmode.BackgroundWeb(args) + if !explicit && terminalOutput(output) && terminalOutput(os.Stdin) { + writeTUIStartup(output, true, productidentity.DefaultAppTitle()) + return + } + _, _ = consolewindow.HideBackground(explicit) + // The previous updater may still share its classic console until the + // 10-second health commit. One bounded recheck handles that old launcher + // without hiding a user's shared terminal or introducing idle polling. + time.AfterFunc(12*time.Second, func() { _, _ = consolewindow.HideBackground(explicit) }) + } +} diff --git a/Tools/Controller/cmd/controller/startup_screen_test.go b/Tools/Controller/cmd/controller/startup_screen_test.go new file mode 100644 index 000000000..6fa93b353 --- /dev/null +++ b/Tools/Controller/cmd/controller/startup_screen_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestTUIStartupScreenIsImmediateAndNeverWrittenToPipes(t *testing.T) { + var output bytes.Buffer + writeTUIStartup(&output, false, "Controller") + if output.Len() != 0 { + t.Fatal("startup text leaked into redirected output") + } + writeTUIStartup(&output, true, "Controller") + if !strings.Contains(output.String(), "Controller") || !strings.Contains(output.String(), "Starting terminal interface") { + t.Fatal("missing startup presentation") + } + if strings.Contains(output.String(), "%") { + t.Fatal("invented startup progress") + } +} diff --git a/Tools/Controller/controller.go b/Tools/Controller/controller.go index ed7d6fbc0..b7b3abfb0 100644 --- a/Tools/Controller/controller.go +++ b/Tools/Controller/controller.go @@ -10,6 +10,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -222,9 +223,18 @@ type OutputOperation struct { // StatusUpdate contains one subscribed telemetry result or its polling error. type StatusUpdate struct { - Time time.Time `json:"time"` - Status Status `json:"status"` - Error string `json:"error,omitempty"` + HostInstanceID string `json:"host_instance_id,omitempty"` + ConnectionGeneration uint64 `json:"connection_generation"` + BoardConnected bool `json:"board_connected"` + HaveStatus bool `json:"have_status"` + Time time.Time `json:"time"` + Status Status `json:"status"` + Error string `json:"error,omitempty"` +} + +func (client *Client) CachedStatusObservation() StatusUpdate { + status, have, stamp, generation, connected := client.runtime.ObservedStatus() + return StatusUpdate{Status: status, HaveStatus: have, Time: stamp, ConnectionGeneration: generation, BoardConnected: connected} } // Options configures discovery, transport, tooling, automation, and host policy. @@ -448,6 +458,7 @@ type IlluminationState struct { // Snapshot is a point-in-time view of connection, board, and front-panel state. type Snapshot struct { + HostInstanceID string `json:"host_instance_id,omitempty"` Connected bool `json:"connected"` Paused bool `json:"paused"` Port PortInfo `json:"port"` @@ -488,6 +499,9 @@ type Snapshot struct { HaveStatusLED bool `json:"have_status_led"` StatusLEDUpdated time.Time `json:"status_led_updated,omitempty"` StatusLEDRevision uint64 `json:"status_led_revision,omitempty"` + PWMValues *PWMValues `json:"pwm_values,omitempty"` + PWMRevision uint64 `json:"pwm_revision,omitempty"` + PWMUpdated time.Time `json:"pwm_updated,omitempty"` Motion MotionSnapshot `json:"motion"` Outputs OutputStreamState `json:"outputs"` StripEffects []control.StripEffectDescriptor `json:"strip_effects,omitempty"` @@ -495,6 +509,27 @@ type Snapshot struct { PortProcess PortProcessSnapshot `json:"port_process"` } +// Unknown offline settings are not an EEPROM write payload. Decode their +// presence flag first so an initial pushed offline snapshot remains usable. +func (snapshot *Snapshot) UnmarshalJSON(data []byte) error { + type fields Snapshot + var result Snapshot + wire := struct { + *fields + Settings json.RawMessage `json:"settings"` + }{fields: (*fields)(&result)} + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + if result.HaveSettings { + if err := json.Unmarshal(wire.Settings, &result.Settings); err != nil { + return err + } + } + *snapshot = result + return nil +} + // Event is the normalized event envelope shared by embedders and bridge clients. type Event struct { ID uint64 `json:"id"` @@ -602,9 +637,6 @@ type Client struct { doneOnce sync.Once statusHub statusSubscriptionHub statusFetch func(context.Context) (Status, error) - pwmObservationMu sync.Mutex - pwmObservation PWMValues - havePWMObservation bool illuminationMu sync.RWMutex illumination IlluminationState illuminationPollAt time.Time @@ -1911,7 +1943,6 @@ func (client *Client) PWMValues(ctx context.Context) (PWMValues, error) { if err != nil { return PWMValues{}, err } - client.PublishPWMValues(values) return values, nil } @@ -1920,33 +1951,7 @@ func (client *Client) PWMValues(ctx context.Context) (PWMValues, error) { // embedders that already hold an equivalent board readback may also publish it // without issuing another serial transaction. func (client *Client) PublishPWMValues(values PWMValues) { - if !client.shouldPublishPWMValues(values) { - return - } - metadata := map[string]string{ - "available": strconv.FormatBool(values.Available), - "selected_channel": strconv.Itoa(int(values.SelectedChannel)), - } - for channel, value := range values.Values { - metadata[fmt.Sprintf("pwm.%d", channel)] = strconv.Itoa(int(value)) - } - client.runtime.PublishStructuredEvent(control.Event{ - Kind: "pwm.changed", - Stream: control.EventStreamState, - Text: "PWM outputs updated", - Metadata: metadata, - }) -} - -func (client *Client) shouldPublishPWMValues(values PWMValues) bool { - client.pwmObservationMu.Lock() - defer client.pwmObservationMu.Unlock() - if client.havePWMObservation && client.pwmObservation == values { - return false - } - client.pwmObservation = values - client.havePWMObservation = true - return true + client.runtime.PublishPWMValues(values) } // Illumination reads the persisted policy, live door state, and exact applied @@ -2366,11 +2371,12 @@ func (client *Client) Snapshot() Snapshot { HaveStatusLED: snapshot.HaveStatusLED, StatusLEDUpdated: snapshot.StatusLEDUpdated, StatusLEDRevision: snapshot.StatusLEDRevision, - Motion: snapshot.Motion, - Outputs: client.outputs.State(), - StripEffects: stripEffects, - Illumination: illumination, - PortProcess: snapshot.PortProcess, + PWMValues: snapshot.PWMValues, PWMRevision: snapshot.PWMRevision, PWMUpdated: snapshot.PWMUpdated, + Motion: snapshot.Motion, + Outputs: client.outputs.State(), + StripEffects: stripEffects, + Illumination: illumination, + PortProcess: snapshot.PortProcess, } } diff --git a/Tools/Controller/controller_pwm_test.go b/Tools/Controller/controller_pwm_test.go index b2df26c02..31bc594d7 100644 --- a/Tools/Controller/controller_pwm_test.go +++ b/Tools/Controller/controller_pwm_test.go @@ -1,26 +1,47 @@ package controller -import "testing" +import ( + "encoding/json" + "pccontroller.local/controller/internal/control" + "testing" +) -func TestShouldPublishPWMValuesDeduplicatesOnlyIdenticalSnapshots(t *testing.T) { - client := &Client{} +func TestPublishPWMValuesDeduplicatesOnlyIdenticalSnapshots(t *testing.T) { + runtime := control.New(control.Options{}) + defer runtime.Close() + client := &Client{runtime: runtime} initial := PWMValues{Available: true, SelectedChannel: 11} initial.Values[11] = 642 - - if !client.shouldPublishPWMValues(initial) { - t.Fatal("first authoritative snapshot must be published") + client.PublishPWMValues(initial) + if runtime.Snapshot().PWMRevision != 1 { + t.Fatal("first readback missing") } - if client.shouldPublishPWMValues(initial) { - t.Fatal("identical authoritative snapshot must not be republished") + client.PublishPWMValues(initial) + if runtime.Snapshot().PWMRevision != 1 { + t.Fatal("unchanged readback republished") } - changed := initial changed.Values[11]++ - if !client.shouldPublishPWMValues(changed) { - t.Fatal("changed channel value must be published") + client.PublishPWMValues(changed) + if runtime.Snapshot().PWMRevision != 2 { + t.Fatal("changed value missing") } changed.SelectedChannel = 12 - if !client.shouldPublishPWMValues(changed) { - t.Fatal("changed selected channel must be published") + client.PublishPWMValues(changed) + if runtime.Snapshot().PWMRevision != 3 { + t.Fatal("selection change missing") + } +} + +func TestSnapshotDecodeOfflineUnknownSettings(t *testing.T) { + var snapshot Snapshot + if err := json.Unmarshal([]byte(`{"connected":false,"have_settings":false,"settings":{"motion_break":0}}`), &snapshot); err != nil { + t.Fatalf("truthful offline snapshot must decode: %v", err) + } + if snapshot.HaveSettings { + t.Fatal("unknown settings became available") + } + if err := json.Unmarshal([]byte(`{"connected":true,"have_settings":true,"settings":{"motion_break":0}}`), &snapshot); err == nil { + t.Fatal("available settings must still pass the native write validator") } } diff --git a/Tools/Controller/examples/udp-peer/main.go b/Tools/Controller/examples/udp-peer/main.go new file mode 100644 index 000000000..f8cbb221c --- /dev/null +++ b/Tools/Controller/examples/udp-peer/main.go @@ -0,0 +1,72 @@ +// 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" + "pccontroller.local/controller/internal/envfile" + udp "pccontroller.local/controller/transport/udp" + "time" +) + +func main() { + _, err := envfile.LoadProcess() + must(err) + 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/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/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/appconfig/peripherals.go b/Tools/Controller/internal/appconfig/peripherals.go index 6745ba45f..a90f80ff5 100644 --- a/Tools/Controller/internal/appconfig/peripherals.go +++ b/Tools/Controller/internal/appconfig/peripherals.go @@ -281,6 +281,22 @@ func ProfileDescriptors(mode string, exposeRawRelays bool, legacyNames map[strin return peripherals, controls } +// PWMDisplayNames uses the same presentation resolver as the full catalog, +// without allocating unrelated controls or a second snapshot at frame cadence. +func PWMDisplayNames(ui UI, presentation map[string]PeripheralPresentation) [PWMChannelCount]string { + var names [PWMChannelCount]string + for channel := range names { + key := PWMChannelKey(channel) + fallback, _ := PeripheralDefaultName(key) + name := strings.TrimSpace(resolvePresentation(key, fallback, ui.PeripheralNames, presentation).Name) + if name == "" { + name = fallback + } + names[channel] = name + } + return names +} + func resolvePresentation(key, defaultName string, legacyNames map[string]string, presentation map[string]PeripheralPresentation) PeripheralPresentation { result := presentation[key] if result.Name == "" { diff --git a/Tools/Controller/internal/appconfig/pwm_names_test.go b/Tools/Controller/internal/appconfig/pwm_names_test.go new file mode 100644 index 000000000..a804f077a --- /dev/null +++ b/Tools/Controller/internal/appconfig/pwm_names_test.go @@ -0,0 +1,42 @@ +package appconfig + +import ( + "strings" + "testing" +) + +func TestPWMDisplayNamesMatchFullProfileCatalog(t *testing.T) { + ui := Defaults().UI + ui.PeripheralNames = map[string]string{"pwm.1": "old", "pwm.2": "Chandelier / Loostr"} + presentation := map[string]PeripheralPresentation{ + "pwm.1": {Name: " Window "}, "pwm.3": {Name: "Stage Light"}, + "pwm.4": {Name: "Garden"}, "pwm.5": {Name: " "}, + } + for _, mode := range []string{BoardModeUnconfigured, BoardModeOrdinaryRelays, BoardModeCinemaSeatMotion} { + for _, raw := range []bool{false, true} { + names := PWMDisplayNames(ui, presentation) + peripherals, controls := ProfileDescriptors(mode, raw, ui.PeripheralNames, presentation) + ApplyPWMChannelConfig(peripherals, controls, ui) + seen := 0 + for _, item := range peripherals { + if item.Kind != "pwm" { + continue + } + want := strings.TrimSpace(item.Name) + if want == "" { + want, _ = PeripheralDefaultName(item.Key) + } + if names[item.Index] != want { + t.Fatalf("%s raw=%v: %s=%q, catalog=%q", mode, raw, item.Key, names[item.Index], want) + } + seen++ + } + if seen != PWMChannelCount { + t.Fatalf("missing PWM channels: %d", seen) + } + if names[1] != "Window" || names[2] != "Chandelier / Loostr" { + t.Fatal("profile/legacy rename precedence changed") + } + } + } +} diff --git a/Tools/Controller/internal/artifacts/selfupdate_process_windows_test.go b/Tools/Controller/internal/artifacts/selfupdate_process_windows_test.go index 6aca1dc04..43ef677e0 100644 --- a/Tools/Controller/internal/artifacts/selfupdate_process_windows_test.go +++ b/Tools/Controller/internal/artifacts/selfupdate_process_windows_test.go @@ -7,12 +7,38 @@ import ( "os" "os/exec" "reflect" + "syscall" "testing" "time" "golang.org/x/sys/windows" + "pccontroller.local/controller/internal/consolewindow" ) +func TestBackgroundReplacementFlagsPreserveTUIAndSharedTerminals(t *testing.T) { + for _, test := range []struct { + args []string + interactive, hidden bool + soleOwner bool + }{ + {[]string{"web", "--no-open", "--no-tray"}, false, true, false}, + {[]string{"web", "--no-open", "--no-tray"}, true, false, true}, + {[]string{"web", "--no-tui", "--no-open", "--no-tray"}, true, true, true}, + {[]string{"web", "--no-tui"}, true, false, false}, + {[]string{"web", "--no-open", "--no-tray"}, true, false, false}, + {[]string{"web"}, true, false, true}, + {nil, false, false, true}, + {[]string{"--config", "custom.json", "tui"}, false, false, true}, + } { + command := exec.Command("controller.exe") + command.SysProcAttr = &syscall.SysProcAttr{} + configureReplacementConsole(command, test.args, consolewindow.State{InteractiveStreams: test.interactive, SoleOwner: test.soleOwner}) + if got := command.SysProcAttr.CreationFlags&windows.CREATE_NO_WINDOW != 0; got != test.hidden { + t.Fatalf("args=%q interactive=%v hidden=%v", test.args, test.interactive, got) + } + } +} + func TestWindowsReplacementLaunchClearsConsoleHandlerForCandidateAndRollback(t *testing.T) { oldSetConsoleCtrlIgnored := setReplacementConsoleCtrlIgnored oldStartCommand := startReplacementCommand diff --git a/Tools/Controller/internal/artifacts/selfupdate_windows.go b/Tools/Controller/internal/artifacts/selfupdate_windows.go index 003105181..53673b0a6 100644 --- a/Tools/Controller/internal/artifacts/selfupdate_windows.go +++ b/Tools/Controller/internal/artifacts/selfupdate_windows.go @@ -9,9 +9,12 @@ import ( "os" "os/exec" "path/filepath" + "syscall" "time" "golang.org/x/sys/windows" + "pccontroller.local/controller/internal/consolewindow" + "pccontroller.local/controller/internal/startupmode" ) type platformHelperLauncher struct{} @@ -32,6 +35,9 @@ var ( func PrepareSelfUpdateHelperProcess() error { return setConsoleCtrlIgnored(true) } func setConsoleCtrlIgnored(ignored bool) error { + if !consolewindow.Inspect().Attached { + return nil + } value := uintptr(0) if ignored { value = 1 @@ -44,6 +50,7 @@ func setConsoleCtrlIgnored(ignored bool) error { } func platformStartReplacementProcess(command *exec.Cmd) error { + configureReplacementConsole(command, command.Args[1:], consolewindow.Inspect()) if err := setReplacementConsoleCtrlIgnored(false); err != nil { return err } @@ -61,15 +68,36 @@ func platformStartReplacementProcess(command *exec.Cmd) error { } func (platformHelperLauncher) Launch(_ context.Context, helperPath, journalPath string) error { + journal, err := loadSelfUpdateJournal(journalPath) + if err != nil { + return fmt.Errorf("read prepared self-update presentation: %w", err) + } command := exec.Command(helperPath, selfUpdateHelperCommand, journalPath) command.Env = withoutSelfUpdateEnvironment(os.Environ()) inheritSelfUpdateIO(command) + configureReplacementConsole(command, journal.Arguments, consolewindow.Inspect()) if err := command.Start(); err != nil { return err } return command.Process.Release() } +func configureReplacementConsole(command *exec.Cmd, arguments []string, console consolewindow.State) { + preserveInteractive := console.InteractiveStreams && (!startupmode.BackgroundWeb(arguments) || !console.SoleOwner) + if command == nil || startupmode.TUI(arguments) || preserveInteractive { + return + } + if command.SysProcAttr == nil { + command.SysProcAttr = &syscall.SysProcAttr{} + } + // Never combine CREATE_NO_WINDOW with conflicting console flags. + if command.SysProcAttr.CreationFlags&(windows.CREATE_NEW_CONSOLE|windows.DETACHED_PROCESS) != 0 { + return + } + command.SysProcAttr.HideWindow = true + command.SysProcAttr.CreationFlags |= windows.CREATE_NO_WINDOW +} + func platformHelperExtension() string { return ".helper.exe" } func replaceExecutable(source, destination string) error { diff --git a/Tools/Controller/internal/consolewindow/consolewindow.go b/Tools/Controller/internal/consolewindow/consolewindow.go index ab0b6b8ae..f604e64c0 100644 --- a/Tools/Controller/internal/consolewindow/consolewindow.go +++ b/Tools/Controller/internal/consolewindow/consolewindow.go @@ -36,6 +36,21 @@ type Result struct { var setTitle = setTitlePlatform +// State describes only this process's attached console, never arbitrary windows. +type State struct { + Supported bool + Attached bool + Visible bool + SoleOwner bool + InteractiveStreams bool +} + +func Inspect() State { return inspectPlatform() } + +// HideBackground preserves shared terminals. Explicit UI-less Web hosts may +// still inherit console streams from their original Windows launcher. +func HideBackground(explicit bool) (Result, error) { return hideBackgroundPlatform(explicit) } + // Validate rejects values that cannot be represented safely by the Win32 // classic-console APIs or by the TUI's minimum usable layout. func Validate(settings Settings) error { diff --git a/Tools/Controller/internal/consolewindow/consolewindow_other.go b/Tools/Controller/internal/consolewindow/consolewindow_other.go index a469e044e..3ef275cc4 100644 --- a/Tools/Controller/internal/consolewindow/consolewindow_other.go +++ b/Tools/Controller/internal/consolewindow/consolewindow_other.go @@ -4,6 +4,11 @@ package consolewindow import "runtime" +func inspectPlatform() State { return State{} } +func hideBackgroundPlatform(_ bool) (Result, error) { + return Result{Reason: "background classic-console management is Windows-only"}, nil +} + func applyPlatform(Settings) (Result, error) { return Result{Reason: "local console size/font management is unavailable on " + runtime.GOOS}, nil } diff --git a/Tools/Controller/internal/consolewindow/consolewindow_windows.go b/Tools/Controller/internal/consolewindow/consolewindow_windows.go index 955d16b37..48fb1bca2 100644 --- a/Tools/Controller/internal/consolewindow/consolewindow_windows.go +++ b/Tools/Controller/internal/consolewindow/consolewindow_windows.go @@ -5,6 +5,7 @@ package consolewindow import ( "errors" "fmt" + "os" "runtime" "syscall" "unicode/utf16" @@ -59,8 +60,50 @@ var ( procGetCurrentConsoleFontEx = consoleKernel32.NewProc("GetCurrentConsoleFontEx") procSetCurrentConsoleFontEx = consoleKernel32.NewProc("SetCurrentConsoleFontEx") procIsWindowVisible = consoleUser32.NewProc("IsWindowVisible") + procShowWindow = consoleUser32.NewProc("ShowWindow") + procGetConsoleProcessList = consoleKernel32.NewProc("GetConsoleProcessList") ) +func inspectPlatform() State { + state := State{Supported: true} + var processes [32]uint32 + count, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&processes[0])), uintptr(len(processes))) + state.Attached = count > 0 + state.SoleOwner = count == 1 && processes[0] == uint32(os.Getpid()) + hwnd, _, _ := procGetConsoleWindow.Call() + if hwnd != 0 { + visible, _, _ := procIsWindowVisible.Call(hwnd) + state.Visible = visible != 0 + } + for _, file := range []*os.File{os.Stdin, os.Stdout, os.Stderr} { + if file == nil { + continue + } + var mode uint32 + if ok, _, _ := procGetConsoleMode.Call(file.Fd(), uintptr(unsafe.Pointer(&mode))); ok != 0 { + state.InteractiveStreams = true + } + } + return state +} + +func hideBackgroundPlatform(explicit bool) (Result, error) { + state := inspectPlatform() + if !state.Visible { + return Result{Reason: "no visible classic console"}, nil + } + if !state.SoleOwner || (state.InteractiveStreams && !explicit) { + return Result{Reason: "interactive or shared console is preserved"}, nil + } + hwnd, _, _ := procGetConsoleWindow.Call() + procShowWindow.Call(hwnd, 0) // SW_HIDE; only this process's sole-owned window. + visible, _, _ := procIsWindowVisible.Call(hwnd) + if visible != 0 { + return Result{}, errors.New("background console remained visible") + } + return Result{Applied: true}, nil +} + func setTitlePlatform(value string) (Result, error) { title, err := windows.UTF16PtrFromString(value) if err != nil { diff --git a/Tools/Controller/internal/control/live_values.go b/Tools/Controller/internal/control/live_values.go new file mode 100644 index 000000000..435cdb341 --- /dev/null +++ b/Tools/Controller/internal/control/live_values.go @@ -0,0 +1,83 @@ +package control + +import ( + "fmt" + "strconv" + "time" + + "pccontroller.local/controller/internal/native" +) + +// ObservedStatus is a small coherent cache read, not a device transaction or +// a catalog expansion. Status pushes must not relabel an old board's sample. +func (runtime *Runtime) ObservedStatus() (native.Status, bool, time.Time, uint64, bool) { + runtime.mu.RLock() + defer runtime.mu.RUnlock() + return runtime.status, runtime.haveStatus, runtime.statusUpdated, runtime.generation, runtime.session != nil +} + +// PublishPWMValues admits only a complete authoritative readback. Embedders +// already holding that readback need not issue another UART request. +func (runtime *Runtime) PublishPWMValues(values native.PWMValues) { + runtime.mu.Lock() + defer runtime.mu.Unlock() + runtime.publishPWMValuesLocked(values) +} + +func (runtime *Runtime) publishPWMValuesLocked(values native.PWMValues) { + for _, value := range values.Values { + if value > 4095 { + return + } + } + if values.SelectedChannel > 15 || (runtime.havePWMValues && runtime.pwmValues == values) { + return + } + runtime.pwmValues, runtime.havePWMValues = values, true + runtime.pwmRevision++ + runtime.pwmUpdated = time.Now() + metadata := map[string]string{ + "available": strconv.FormatBool(values.Available), + "selected_channel": strconv.Itoa(int(values.SelectedChannel)), + "revision": strconv.FormatUint(runtime.pwmRevision, 10), + "connection_generation": strconv.FormatUint(runtime.generation, 10), + } + for channel, value := range values.Values { + metadata[fmt.Sprintf("pwm.%d", channel)] = strconv.Itoa(int(value)) + } + runtime.publishEvent(Event{Kind: "pwm.changed", Stream: EventStreamState, Source: "board", Target: "app.clients", Text: "PWM outputs updated", Metadata: metadata}) +} + +// Retain the trailing compositor frame inside the bounded presentation window. +// No timer reads a device or survives a changed owner. +func (runtime *Runtime) scheduleStatusLEDFlushLocked() { + if runtime.statusLEDFlush != nil { + return + } + generation := runtime.generation + delay := time.Until(runtime.statusLEDPublishedAt.Add(statusLEDPresentationInterval)) + if delay < 0 { + delay = 0 + } + var scheduled *time.Timer + scheduled = time.AfterFunc(delay, func() { + runtime.mu.Lock() + defer runtime.mu.Unlock() + if runtime.statusLEDFlush != scheduled { + return + } + runtime.statusLEDFlush = nil + if generation != runtime.generation || !runtime.haveStatusLED || + (runtime.haveStatusLEDPublished && runtime.statusLEDPublished == runtime.statusLED) { + return + } + state := runtime.statusLED + runtime.statusLEDPublished, runtime.haveStatusLEDPublished, runtime.statusLEDPublishedAt = state, true, time.Now() + runtime.publishEvent(Event{Kind: "status_led.changed", Stream: EventStreamState, Source: "board", Target: "app.clients", Text: "Status LED updated", Metadata: map[string]string{ + "red": strconv.Itoa(int(state.Red)), "green": strconv.Itoa(int(state.Green)), "blue": strconv.Itoa(int(state.Blue)), + "brightness": strconv.Itoa(int(state.Brightness)), "effect": strconv.Itoa(int(state.Effect)), "condition": strconv.Itoa(int(state.Condition)), + "revision": strconv.FormatUint(runtime.statusLEDRevision, 10), "connection_generation": strconv.FormatUint(generation, 10), + }}) + }) + runtime.statusLEDFlush = scheduled +} diff --git a/Tools/Controller/internal/control/live_values_test.go b/Tools/Controller/internal/control/live_values_test.go new file mode 100644 index 000000000..b9f72f87e --- /dev/null +++ b/Tools/Controller/internal/control/live_values_test.go @@ -0,0 +1,87 @@ +package control + +import ( + "context" + "encoding/binary" + "pccontroller.local/controller/internal/native" + "testing" + "time" +) + +func TestPWMFramePublishesCompleteChangedOnlyOwnedCache(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + payload := make([]byte, 34) + payload[0] = 1 + payload[1] = 2 + binary.LittleEndian.PutUint16(payload[6:8], 1234) + runtime.observe(native.Frame{Opcode: native.OpPWMValues, Payload: payload}) + first := runtime.Snapshot() + if first.PWMValues == nil || first.PWMValues.Values[2] != 1234 || first.PWMRevision != 1 { + t.Fatalf("cache=%+v", first.PWMValues) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + event, err := runtime.WaitEventStreamFilter(ctx, 0, "pwm.changed", nil, EventStreamState) + if err != nil || event.Metadata["pwm.2"] != "1234" || event.Metadata["revision"] != "1" { + t.Fatalf("event=%+v err=%v", event, err) + } + runtime.observe(native.Frame{Opcode: native.OpPWMValues, Payload: payload}) + if runtime.Snapshot().PWMRevision != 1 { + t.Fatal("unchanged frame republished") + } + runtime.mu.Lock() + runtime.clearPeerStateLocked() + runtime.generation++ + runtime.mu.Unlock() + if runtime.Snapshot().PWMValues != nil { + t.Fatal("old board cache survived detach") + } + runtime.observe(native.Frame{Opcode: native.OpPWMValues, Payload: payload}) + if runtime.Snapshot().PWMRevision != 2 { + t.Fatal("same values on new board lost") + } + binary.LittleEndian.PutUint16(payload[6:8], 5000) + runtime.observe(native.Frame{Opcode: native.OpPWMValues, Payload: payload}) + if runtime.Snapshot().PWMRevision != 2 { + t.Fatal("invalid raw PWM admitted") + } +} + +func TestStatusLEDQuietTrailingFrameFlushesWithoutAnotherObservation(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + first := native.StatusLEDState{Red: 40, Brightness: 255} + runtime.observe(native.Frame{Opcode: native.OpStatusLEDChanged, Payload: []byte{40, 0, 0, 255, 0, 0}}) + if !runtime.shouldPublishStatusLED(first, time.Now()) { + t.Fatal("initial frame lost") + } + runtime.observe(native.Frame{Opcode: native.OpStatusLEDChanged, Payload: []byte{41, 0, 0, 255, 0, 0}}) + if runtime.shouldPublishStatusLED(native.StatusLEDState{Red: 41, Brightness: 255}, time.Now()) { + t.Fatal("frame not bounded") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + event, err := runtime.WaitEventStreamFilter(ctx, 0, "status_led.changed", nil, EventStreamState) + if err != nil || event.Metadata["red"] != "41" || event.Metadata["revision"] != "2" { + t.Fatalf("trailing event=%+v err=%v", event, err) + } +} + +func TestStatusLEDTrailingFrameIsRetiredOnOwnerChange(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + runtime.observe(native.Frame{Opcode: native.OpStatusLEDChanged, Payload: []byte{40, 0, 0, 255, 0, 0}}) + runtime.shouldPublishStatusLED(native.StatusLEDState{Red: 40, Brightness: 255}, time.Now()) + runtime.observe(native.Frame{Opcode: native.OpStatusLEDChanged, Payload: []byte{41, 0, 0, 255, 0, 0}}) + runtime.shouldPublishStatusLED(native.StatusLEDState{Red: 41, Brightness: 255}, time.Now()) + runtime.mu.Lock() + runtime.clearPeerStateLocked() + runtime.generation++ + runtime.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + if event, err := runtime.WaitEventStreamFilter(ctx, 0, "status_led.changed", nil, EventStreamState); err == nil { + t.Fatalf("retired frame published: %+v", event) + } +} diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 82070b6ac..4aa9a24b7 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -65,6 +65,9 @@ type Snapshot struct { // host process. StatusLEDEpoch uint64 StatusLEDRevision uint64 + PWMValues *native.PWMValues + PWMRevision uint64 + PWMUpdated time.Time Motion MotionSnapshot ProgramState ProgramStateSnapshot Programming ProgrammingSnapshot @@ -247,6 +250,11 @@ type Runtime struct { statusLEDPublished native.StatusLEDState haveStatusLEDPublished bool statusLEDPublishedAt time.Time + statusLEDFlush *time.Timer + pwmValues native.PWMValues + havePWMValues bool + pwmRevision uint64 + pwmUpdated time.Time motion MotionSnapshot motionRevision uint64 motionIntentToken [2]uint64 @@ -827,6 +835,11 @@ func (runtime *Runtime) Snapshot() Snapshot { EmergencyStop: emergencyStop, HardwareProblems: hardwareProblems, PortProcess: runtime.portProcess, + PWMRevision: runtime.pwmRevision, PWMUpdated: runtime.pwmUpdated, + } + if runtime.havePWMValues { + values := runtime.pwmValues + snapshot.PWMValues = &values } runner := runtime.macroRunner runtime.mu.RUnlock() @@ -1165,6 +1178,14 @@ func (runtime *Runtime) clearPeerStateLocked() { runtime.statusLED = native.StatusLEDState{} runtime.haveStatusLED = false runtime.statusLEDUpdated = time.Time{} + if runtime.statusLEDFlush != nil { + runtime.statusLEDFlush.Stop() + runtime.statusLEDFlush = nil + } + runtime.haveStatusLEDPublished = false + runtime.havePWMValues = false + runtime.pwmValues = native.PWMValues{} + runtime.pwmUpdated = time.Time{} runtime.clearMotionLocked() } @@ -3053,7 +3074,10 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { return } } else { - statusLEDRevision := runtime.observe(event.Frame) + statusLEDRevision, current := runtime.observeAtGeneration(event.Frame, generation) + if !current { + continue + } kind := "rx" text := fmt.Sprintf( "%s seq=%d payload=% X", @@ -3186,14 +3210,15 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { Kind: kind, Text: text, Frame: event.Frame, Source: "board", Target: "host", MessageType: "event", Metadata: map[string]string{ - "red": strconv.Itoa(int(parsedStatusLED.Red)), - "green": strconv.Itoa(int(parsedStatusLED.Green)), - "blue": strconv.Itoa(int(parsedStatusLED.Blue)), - "hex": fmt.Sprintf("#%02X%02X%02X", parsedStatusLED.Red, parsedStatusLED.Green, parsedStatusLED.Blue), - "brightness": strconv.Itoa(int(parsedStatusLED.Brightness)), - "effect": strconv.Itoa(int(parsedStatusLED.Effect)), - "condition": strconv.Itoa(int(parsedStatusLED.Condition)), - "revision": strconv.FormatUint(statusLEDRevision, 10), + "red": strconv.Itoa(int(parsedStatusLED.Red)), + "green": strconv.Itoa(int(parsedStatusLED.Green)), + "blue": strconv.Itoa(int(parsedStatusLED.Blue)), + "hex": fmt.Sprintf("#%02X%02X%02X", parsedStatusLED.Red, parsedStatusLED.Green, parsedStatusLED.Blue), + "brightness": strconv.Itoa(int(parsedStatusLED.Brightness)), + "effect": strconv.Itoa(int(parsedStatusLED.Effect)), + "condition": strconv.Itoa(int(parsedStatusLED.Condition)), + "revision": strconv.FormatUint(statusLEDRevision, 10), + "connection_generation": strconv.FormatUint(generation, 10), }, }) } else if parsedDevice != nil { @@ -3294,6 +3319,7 @@ func (runtime *Runtime) shouldPublishStatusLED(state native.StatusLEDState, obse state.Condition != runtime.statusLEDPublished.Condition || statusLEDColorDistance(state, runtime.statusLEDPublished) >= 64 if !immediate && observedAt.Sub(runtime.statusLEDPublishedAt) < statusLEDPresentationInterval { + runtime.scheduleStatusLEDFlushLocked() return false } runtime.statusLEDPublished = state @@ -3484,6 +3510,10 @@ func (runtime *Runtime) observeAtGeneration(frame native.Frame, generation uint6 func (runtime *Runtime) observeLocked(frame native.Frame) uint64 { switch frame.Opcode { + case native.OpPWMValues: + if values, err := native.ParsePWMValues(frame.Payload); err == nil { + runtime.publishPWMValuesLocked(values) + } case native.OpStatus: if status, err := native.ParseStatus(frame.Payload); err == nil { now := time.Now() diff --git a/Tools/Controller/internal/control/strip.go b/Tools/Controller/internal/control/strip.go index 8c01a9efc..3ff5d5c69 100644 --- a/Tools/Controller/internal/control/strip.go +++ b/Tools/Controller/internal/control/strip.go @@ -318,6 +318,9 @@ func (outputs *OutputScheduler) streamStripEffect(ctx context.Context, count, fp return err } consecutiveTimeouts++ + if consecutiveTimeouts >= 3 { + return fmt.Errorf("strip stream stopped after repeated missing acknowledgements; output state is unconfirmed: %w", err) + } if consecutiveTimeouts == 1 { outputs.target.PublishHostEvent( "output", diff --git a/Tools/Controller/internal/control/strip_test.go b/Tools/Controller/internal/control/strip_test.go index eab0b8e60..d6efdcabb 100644 --- a/Tools/Controller/internal/control/strip_test.go +++ b/Tools/Controller/internal/control/strip_test.go @@ -131,6 +131,18 @@ func TestNextStripFrameTimeSkipsBufferedIntervals(t *testing.T) { } } +func TestStripStreamStopsAfterRepeatedUnconfirmedFrames(t *testing.T) { + target := &recordingOutputTarget{failCommands: map[int]error{ + 1: context.DeadlineExceeded, 2: context.DeadlineExceeded, 3: context.DeadlineExceeded, + }} + outputs := NewOutputScheduler(target) + defer outputs.Close() + err := outputs.streamStripEffect(context.Background(), 1, 30, func(count int, elapsed time.Duration) []byte { return stripRainbowFrame(count, 0) }) + if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "unconfirmed") || len(target.snapshotCommands()) != 3 { + t.Fatalf("stream continued after repeated missing ACKs: err=%v commands=%d", err, len(target.snapshotCommands())) + } +} + func TestStripEffectCatalogUsesExactConfiguredIDs(t *testing.T) { for _, id := range []string{"police", "white-thunder", "converging-red"} { definition, program, ok := stripEffectByID(id) 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/buzzer_mirror.go b/Tools/Controller/internal/hostbridge/buzzer_mirror.go index eed47828b..988c11b52 100644 --- a/Tools/Controller/internal/hostbridge/buzzer_mirror.go +++ b/Tools/Controller/internal/hostbridge/buzzer_mirror.go @@ -63,7 +63,12 @@ func (timeline *buzzerPlaybackTimeline) plan(job buzzerMirrorJob, now time.Time) } start := observedAt if job.timed { + fresh := !observedAt.After(now) && now.Sub(observedAt) <= maxBuzzerObservedSkew start = observedAt.Add(buzzerPlayoutDelay) + if fresh && !start.After(now) { + // Integration/scheduler handoff can itself miss the cushion. + start = now.Add(buzzerPlayoutDelay) + } if previous, ok := timeline.anchors[job.source]; ok { delta := time.Duration(uint32(job.deviceMicros-previous.deviceMicros)) * time.Microsecond candidate := previous.start.Add(delta) @@ -72,7 +77,12 @@ func (timeline *buzzerPlaybackTimeline) plan(job buzzerMirrorJob, now time.Time) withinObservedWindow := !candidate.Before(observedAt.Add(-maxBuzzerObservedSkew)) && !candidate.After(observedAt.Add(maxBuzzerObservedSkew)) if sameGeneration && delta <= maxBuzzerSourceGap && withinObservedWindow { - start = candidate + // A fresh note that missed the cushion must not lose its entire + // duration. Re-anchor this source, then retain device cadence from + // that anchor. Do not rehabilitate a stale queued observation. + if !candidate.Before(now) || !fresh { + start = candidate + } } } timeline.anchors[job.source] = buzzerTimelineAnchor{ @@ -98,6 +108,23 @@ func (plan buzzerPlaybackPlan) remaining(now time.Time) (time.Duration, bool) { return plan.end.Sub(now), true } +func (timeline *buzzerPlaybackTimeline) rebaseForStart(job buzzerMirrorJob, plan buzzerPlaybackPlan, now time.Time) (buzzerPlaybackPlan, time.Duration) { + age := now.Sub(job.observedAt) + late := now.Sub(plan.start) + if !job.timed || job.frequencyHz == 0 || job.observedAt.IsZero() || age < 0 || age > maxBuzzerObservedSkew || late <= 0 { + return plan, 0 + } + // Timer wakeup and serialized backend cleanup can miss even a 1 ms note. + // Shift this source's fresh playout cadence, not its declared durations. + plan.start = plan.start.Add(late) + plan.end = plan.end.Add(late) + if anchor, ok := timeline.anchors[job.source]; ok { + anchor.start = anchor.start.Add(late) + timeline.anchors[job.source] = anchor + } + return plan, late +} + func buzzerMirrorJobFor(config appconfig.BuzzerMirror, event controller.Event) (buzzerMirrorJob, bool) { if !config.Enabled || !config.NativeEnabled || event.Kind != "buzzer.note" { return buzzerMirrorJob{}, false @@ -138,9 +165,12 @@ func (manager *Manager) dispatchBuzzerMirror(config appconfig.Config, event cont if !ok { return } - // Event.Time may originate on a bridged host with a skewed wall clock. - // Anchor against this host's monotonic receipt time instead. - job.observedAt = time.Now() + // Runtime.publishEvent stamps both local and ingressed events with this + // host's receipt time. Preserve it: stamping again after slow integration + // work disguises queue delay and can replay stale observations as fresh. + if job.observedAt.IsZero() { + job.observedAt = time.Now() + } // Configure-time resolution is authoritative for auto mode. Reusing it // avoids probing the native device before launching the already-selected // optional external helper for every pushed note. @@ -207,8 +237,28 @@ func (manager *Manager) buzzerMirrorLoop() { } timerChannel = timer.C } + sortPending := func() { + sort.SliceStable(pending, func(left, right int) bool { + if pending[left].plan.start.Equal(pending[right].plan.start) { + return pending[left].order < pending[right].order + } + return pending[left].plan.start.Before(pending[right].plan.start) + }) + } start := func(next scheduledPlayback) bool { - remaining, audible := next.plan.remaining(time.Now()) + now := time.Now() + var shift time.Duration + next.plan, shift = timeline.rebaseForStart(next.job, next.plan, now) + if shift > 0 { + for index := range pending { + if pending[index].job.source == next.job.source { + pending[index].plan.start = pending[index].plan.start.Add(shift) + pending[index].plan.end = pending[index].plan.end.Add(shift) + } + } + sortPending() + } + remaining, audible := next.plan.remaining(now) if !audible || next.job.frequencyHz == 0 { return false } @@ -310,12 +360,7 @@ func (manager *Manager) buzzerMirrorLoop() { plan := timeline.plan(job, time.Now()) order++ pending = append(pending, scheduledPlayback{job: job, plan: plan, order: order}) - sort.SliceStable(pending, func(left, right int) bool { - if pending[left].plan.start.Equal(pending[right].plan.start) { - return pending[left].order < pending[right].order - } - return pending[left].plan.start.Before(pending[right].plan.start) - }) + sortPending() case err := <-activeDone: finishActive(err) case <-timerChannel: diff --git a/Tools/Controller/internal/hostbridge/buzzer_mirror_test.go b/Tools/Controller/internal/hostbridge/buzzer_mirror_test.go index db2caeb03..bc5043774 100644 --- a/Tools/Controller/internal/hostbridge/buzzer_mirror_test.go +++ b/Tools/Controller/internal/hostbridge/buzzer_mirror_test.go @@ -149,6 +149,78 @@ func TestBuzzerPlaybackTimelinePreservesDeviceCadenceAndBuffersTransportJitter(t } } +func TestBuzzerPlaybackTimelineRebuffersFreshShortNoteAfterMissedCushion(t *testing.T) { + timeline := newBuzzerPlaybackTimeline() + base := time.Unix(1700000000, 0) + _ = timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 1000, durationMS: 10, observedAt: base}, base) + // Previously this fresh 10 ms note ended at +160 ms and was entirely + // skipped when it arrived at +190 ms. Rebuffer, don't stretch the note. + received := base.Add(190 * time.Millisecond) + late := timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 101000, durationMS: 10, observedAt: received}, received) + if !late.start.Equal(received.Add(buzzerPlayoutDelay)) || late.end.Sub(late.start) != 10*time.Millisecond { + t.Fatalf("fresh short note was truncated: %+v", late) + } + next := timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 121000, durationMS: 10, observedAt: received.Add(time.Millisecond)}, received.Add(time.Millisecond)) + if next.start.Sub(late.start) != 20*time.Millisecond { + t.Fatalf("device cadence lost after recovery: %+v", next) + } +} + +func TestBuzzerPlaybackTimelineDoesNotRebufferStaleQueuedNote(t *testing.T) { + timeline := newBuzzerPlaybackTimeline() + base := time.Unix(1700000000, 0) + _ = timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 1000, durationMS: 10, observedAt: base}, base) + stale := timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 101000, durationMS: 10, observedAt: base.Add(190 * time.Millisecond)}, base.Add(time.Second)) + if _, audible := stale.remaining(base.Add(time.Second)); audible { + t.Fatal("stale queued note was replayed as fresh") + } +} + +func TestBuzzerPlaybackTimelineRebuffersFreshSchedulerHandoff(t *testing.T) { + timeline := newBuzzerPlaybackTimeline() + base := time.Unix(1700000000, 0) + now := base.Add(75 * time.Millisecond) + first := timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 1000, durationMS: 10, observedAt: base}, now) + if !first.start.Equal(now.Add(buzzerPlayoutDelay)) || first.end.Sub(first.start) != 10*time.Millisecond { + t.Fatalf("handoff delay truncated first note: %+v", first) + } + // A later candidate can be newer than receipt yet older than scheduling. + received := base.Add(180 * time.Millisecond) + now = base.Add(240 * time.Millisecond) + next := timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 101000, durationMS: 10, observedAt: received}, now) + if !next.start.Equal(now.Add(buzzerPlayoutDelay)) || next.end.Sub(next.start) != 10*time.Millisecond { + t.Fatalf("handoff delay truncated following note: %+v", next) + } +} + +func TestBuzzerPlaybackTimelineRetainsVeryShortNoteAfterTimerWakeup(t *testing.T) { + timeline := newBuzzerPlaybackTimeline() + base := time.Unix(1700000000, 0) + job := buzzerMirrorJob{source: "board-a", timed: true, deviceMicros: 1000, + frequencyHz: 880, durationMS: 1, observedAt: base} + plan := timeline.plan(job, base) + started := plan.start.Add(2 * time.Millisecond) + rebased, shift := timeline.rebaseForStart(job, plan, started) + if remaining, audible := rebased.remaining(started); !audible || remaining != time.Millisecond || shift != 2*time.Millisecond { + t.Fatalf("short timer wakeup remaining=%v audible=%t shift=%v", remaining, audible, shift) + } + next := timeline.plan(buzzerMirrorJob{source: "board-a", timed: true, + deviceMicros: 21000, durationMS: 1, observedAt: base.Add(20 * time.Millisecond)}, started) + if next.start.Sub(rebased.start) != 20*time.Millisecond { + t.Fatal("timer recovery lost following source cadence") + } + if _, shift := timeline.rebaseForStart(job, plan, base.Add(time.Second)); shift != 0 { + t.Fatal("timer recovery rehabilitated a stale note") + } +} + func TestBuzzerPlaybackTimelineReanchorsAfterDeviceRestart(t *testing.T) { timeline := newBuzzerPlaybackTimeline() base := time.Unix(1700000000, 0) 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/hostui/instances.go b/Tools/Controller/internal/hostui/instances.go index ce9dcc5ce..ea3996ffa 100644 --- a/Tools/Controller/internal/hostui/instances.go +++ b/Tools/Controller/internal/hostui/instances.go @@ -4,8 +4,10 @@ import ( "errors" "os" "path/filepath" + "pccontroller.local/controller/internal/consolewindow" "regexp" "sort" + "strconv" "strings" "sync" "time" @@ -79,11 +81,31 @@ func CurrentProcessSelf(startedAt time.Time) InstanceSelf { if startedAt.IsZero() { startedAt = time.Now().UTC() } - return InstanceSelf{ + self := InstanceSelf{ Kind: "process", ProcessID: os.Getpid(), ParentProcessID: os.Getppid(), ImagePath: imagePath, WorkingDirectory: workingDirectory, StartedAt: startedAt.UTC(), Vars: variables, } + RefreshCurrentProcessConsole(&self) + return self +} + +// RefreshCurrentProcessConsole updates a response copy, never another process or +// the registry. In particular the bounded startup recheck may hide the console +// after registration, so a diagnostic read must not return the initial value. +func RefreshCurrentProcessConsole(self *InstanceSelf) { + if self == nil || self.Kind != "process" || self.ProcessID != os.Getpid() { + return + } + if console := consolewindow.Inspect(); console.Supported { + if self.Vars == nil { + self.Vars = make(map[string]string) + } + self.Vars["console_attached"] = strconv.FormatBool(console.Attached) + self.Vars["console_visible"] = strconv.FormatBool(console.Visible) + self.Vars["console_sole_owner"] = strconv.FormatBool(console.SoleOwner) + self.Vars["console_interactive_streams"] = strconv.FormatBool(console.InteractiveStreams) + } } type InstanceChange struct { diff --git a/Tools/Controller/internal/ipcjson/board_profiles.go b/Tools/Controller/internal/ipcjson/board_profiles.go index fb416ace9..e91b69d8d 100644 --- a/Tools/Controller/internal/ipcjson/board_profiles.go +++ b/Tools/Controller/internal/ipcjson/board_profiles.go @@ -58,6 +58,17 @@ func presentationResult(settings peripheralSettings, key string) (presentationUp func (service *Service) activeBoardProfile() (boardProfileDescriptor, appconfig.BoardProfile) { config := service.hostConfig() snapshot := service.Client.Snapshot() + identity, profile, configured := boardProfileForSnapshot(config, snapshot) + descriptor := boardProfileDescriptor{ + Key: profile.Key, BoardIdentity: identity.Value, IdentitySource: identity.Source, + IdentityStable: identity.Stable, Mode: profile.Mode, ExposeRawRelays: profile.ExposeRawRelays, Configured: configured, + Attached: snapshot.Connected && identity.Value != "", + } + descriptor.Revision = appconfig.BoardProfileRevision(identity.Value, profile, config.UI.PeripheralNames) + return descriptor, profile +} + +func boardProfileForSnapshot(config appconfig.Config, snapshot controller.Snapshot) (appconfig.BoardIdentity, appconfig.BoardProfile, bool) { port := snapshot.Port if strings.TrimSpace(port.SerialNumber) == "" && strings.TrimSpace(port.InstanceID) == "" && strings.TrimSpace(port.Name) == "" && config.Connection.LastDevice != nil { port.SerialNumber = config.Connection.LastDevice.SerialNumber @@ -71,13 +82,7 @@ func (service *Service) activeBoardProfile() (boardProfileDescriptor, appconfig. mode = appconfig.NormalizeBoardMode(profile.Mode) } profile.Mode = mode - descriptor := boardProfileDescriptor{ - Key: profile.Key, BoardIdentity: identity.Value, IdentitySource: identity.Source, - IdentityStable: identity.Stable, Mode: mode, ExposeRawRelays: profile.ExposeRawRelays, Configured: configured, - Attached: snapshot.Connected && identity.Value != "", - } - descriptor.Revision = appconfig.BoardProfileRevision(identity.Value, profile, config.UI.PeripheralNames) - return descriptor, profile + return identity, profile, configured } func (service *Service) updateActiveBoardProfile(key, mode string, exposeRawRelays *bool, expectedRevision string) (boardProfileDescriptor, error) { diff --git a/Tools/Controller/internal/ipcjson/cpu_profile.go b/Tools/Controller/internal/ipcjson/cpu_profile.go new file mode 100644 index 000000000..07e23126c --- /dev/null +++ b/Tools/Controller/internal/ipcjson/cpu_profile.go @@ -0,0 +1,55 @@ +package ipcjson + +import ( + "bytes" + "net" + "net/http" + "runtime/pprof" + "sync/atomic" + "time" +) + +var cpuProfileBusy atomic.Bool + +// A bounded, explicitly requested application profile, never an OS profiler +// or a public pprof/debug listener. No files, configuration or board commands. +func (service *Service) serveCPUProfile(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writer.Header().Set("Allow", http.MethodPost) + writeHTTPJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil || !net.ParseIP(host).IsLoopback() { + writeHTTPJSON(writer, http.StatusForbidden, map[string]string{"error": "application profiling requires a local caller"}) + return + } + if !authorizeHTTPRequest(writer, request, service) { + return + } + if request.ContentLength != 0 || request.URL.RawQuery != "" { + writeHTTPJSON(writer, http.StatusBadRequest, map[string]string{"error": "profile capture accepts no parameters or body"}) + return + } + if !cpuProfileBusy.CompareAndSwap(false, true) { + writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": "application profile already active"}) + return + } + defer cpuProfileBusy.Store(false) + var profile bytes.Buffer + if err := pprof.StartCPUProfile(&profile); err != nil { + writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": "application profiler unavailable"}) + return + } + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + select { + case <-timer.C: + case <-request.Context().Done(): + } + pprof.StopCPUProfile() + writer.Header().Set("Content-Type", "application/octet-stream") + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Disposition", "attachment; filename=controller-cpu.pprof") + _, _ = writer.Write(profile.Bytes()) +} diff --git a/Tools/Controller/internal/ipcjson/cpu_profile_test.go b/Tools/Controller/internal/ipcjson/cpu_profile_test.go new file mode 100644 index 000000000..e8995fe33 --- /dev/null +++ b/Tools/Controller/internal/ipcjson/cpu_profile_test.go @@ -0,0 +1,29 @@ +package ipcjson + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCPUProfileRejectsNonlocalAndCrossOriginRequests(t *testing.T) { + service, _ := browserUIConfigTestService(t) + for _, test := range []struct { + address, origin, method string + status int + }{ + {"192.168.1.40:12000", "", http.MethodPost, http.StatusForbidden}, + {"bad-address", "", http.MethodPost, http.StatusForbidden}, + {"127.0.0.1:12000", "https://hostile.example", http.MethodPost, http.StatusForbidden}, + {"[::1]:12000", "", http.MethodGet, http.StatusMethodNotAllowed}, + } { + request := httptest.NewRequest(test.method, "/api/diagnostics/cpu-profile", nil) + request.RemoteAddr = test.address + request.Header.Set("Origin", test.origin) + response := httptest.NewRecorder() + service.serveCPUProfile(response, request) + if response.Code != test.status { + t.Fatalf("%+v returned %d", test, response.Code) + } + } +} diff --git a/Tools/Controller/internal/ipcjson/ipc.go b/Tools/Controller/internal/ipcjson/ipc.go index 9edc16090..482d656f4 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 { @@ -1058,12 +1063,7 @@ func (service *Service) dispatch( err = &RPCError{Code: -32602, Message: "raw_value must be 0..4095"} } if err == nil { - err = service.rejectLockedPeripheral(fmt.Sprintf("pwm.%d", params.Channel)) - } - if err == nil { - if err = service.Client.SetPWMChannel(ctx, byte(params.Channel), uint16(raw)); err == nil { - result, err = service.pwmValues(ctx) - } + result, err = service.setPWMAndReadback(ctx, byte(params.Channel), uint16(raw)) } } } @@ -1076,9 +1076,7 @@ func (service *Service) dispatch( result, err = service.updatePWMChannelConfig(params.Channel, params.PWMChannelConfig) } case "controller.pwm.off": - if err = service.Client.AllPWMOff(ctx); err == nil { - result, err = service.pwmValues(ctx) - } + result, err = service.clearPWMAndReadback(ctx) case "controller.status_led.set": var params struct { Red int `json:"red"` @@ -1629,6 +1627,7 @@ func (service *Service) dispatch( } else if instance, ok := service.AppInstances.Get(service.CoordinatorInstanceID); !ok { err = errors.New("coordinator bridge instance is not registered") } else { + hostui.RefreshCurrentProcessConsole(instance.Self) result = instance } case "controller.app.instance.get": @@ -1704,6 +1703,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 { @@ -1722,6 +1722,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) } @@ -1730,8 +1732,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 @@ -1745,6 +1748,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, @@ -1869,6 +1873,12 @@ func (service *Service) dispatch( } func dispatchRPCError(err error) *RPCError { + var confirmation *pwmConfirmationError + if errors.As(err, &confirmation) { + return &RPCError{Code: -32010, Message: confirmation.Error(), Data: map[string]any{ + "kind": "confirmation_unavailable", "resource": "pwm", "write_acknowledged": true, "retryable": false, + }} + } var rpcError *RPCError if errors.As(err, &rpcError) { return rpcError @@ -1914,16 +1924,42 @@ func (service *Service) primaryPingResult() map[string]any { type controllerSnapshotEnvelope struct { controller.Snapshot + PWMValues *pwmValues `json:"pwm_values,omitempty"` HostInstanceID string `json:"host_instance_id,omitempty"` MediaPlayback controller.MediaPlaybackSnapshot `json:"media_playback"` } +func (snapshot *controllerSnapshotEnvelope) UnmarshalJSON(data []byte) error { + var core controller.Snapshot + if err := json.Unmarshal(data, &core); err != nil { + return err + } + var fields struct { + HostInstanceID string `json:"host_instance_id"` + MediaPlayback controller.MediaPlaybackSnapshot `json:"media_playback"` + PWMValues *pwmValues `json:"pwm_values"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *snapshot = controllerSnapshotEnvelope{Snapshot: core, HostInstanceID: fields.HostInstanceID, MediaPlayback: fields.MediaPlayback, PWMValues: fields.PWMValues} + return nil +} + func (service *Service) controllerSnapshot() controllerSnapshotEnvelope { - return controllerSnapshotEnvelope{ - Snapshot: service.Client.Snapshot(), + snapshot := service.Client.Snapshot() + envelope := controllerSnapshotEnvelope{ + Snapshot: snapshot, HostInstanceID: strings.TrimSpace(service.HostInstanceID), MediaPlayback: service.Client.MediaPlayback(), } + if snapshot.PWMValues != nil { + values := service.describePWMValuesForSnapshot(*snapshot.PWMValues, snapshot) + values.HostInstanceID, values.ConnectionGeneration = envelope.HostInstanceID, snapshot.ConnectionGeneration + values.Revision, values.Time = snapshot.PWMRevision, snapshot.PWMUpdated + envelope.PWMValues = &values + } + return envelope } func (service *Service) hostConfig() appconfig.Config { @@ -1996,9 +2032,10 @@ func (service *Service) setNetworkPeers(raw json.RawMessage) (networkPeersConfig func (service *Service) browserUISettings() browserUISettings { ui := service.hostConfig().UI - peripherals := appconfig.PeripheralDescriptors() - controls := appconfig.ControlDescriptors(ui.PeripheralNames) - appconfig.ApplyPWMChannelConfig(peripherals, controls, ui) + if service.Client.Snapshot().Hello.BuildFeatures&native.BuildFeatureNativeHAL != 0 { + ui.PeripheralNames = appconfig.NativeSensorNames(ui.PeripheralNames) + } + catalog := service.peripheralSettings() return browserUISettings{ AppTitle: productidentity.Title(ui.AppTitle), Tagline: ui.Tagline, @@ -2011,8 +2048,8 @@ func (service *Service) browserUISettings() browserUISettings { SegmentScroll: ui.SegmentScroll, PeripheralNames: clonePeripheralNames(ui.PeripheralNames), PWMChannels: clonePWMChannels(ui.PWMChannels), - Peripherals: peripherals, - Controls: controls, + Peripherals: catalog.Peripherals, + Controls: catalog.Controls, } } @@ -2054,6 +2091,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) @@ -2243,6 +2283,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 } @@ -2266,6 +2311,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", @@ -2298,7 +2347,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 } } @@ -2417,6 +2466,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 @@ -2745,6 +2802,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{ @@ -2987,6 +3045,7 @@ func websocketMux(serverContext context.Context, service *Service) http.Handler webSocketPath := service.currentWebSocketPath() socketIOPath := service.currentSocketIOPath() mux := http.NewServeMux() + mux.HandleFunc("/api/diagnostics/cpu-profile", service.serveCPUProfile) mux.HandleFunc(webSocketPath, func(writer http.ResponseWriter, request *http.Request) { if !authorizeHTTPRequest(writer, request, service) { return @@ -3176,6 +3235,8 @@ func websocketMux(serverContext context.Context, service *Service) http.Handler if !authorizeHTTPCapability(writer, request, service, capabilityBoard) { return } + var values pwmValues + var err error if request.Method == http.MethodPut { var params struct { Channel int `json:"channel"` @@ -3206,17 +3267,12 @@ func websocketMux(serverContext context.Context, service *Service) http.Handler writeHTTPJSON(writer, http.StatusBadRequest, map[string]string{"error": "raw_value must be 0..4095"}) return } - if err := service.Client.SetPWMChannel(request.Context(), byte(params.Channel), uint16(raw)); err != nil { - writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": err.Error()}) - return - } - } else if err := service.Client.AllPWMOff(request.Context()); err != nil { - writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": err.Error()}) - return + values, err = service.setPWMAndReadback(request.Context(), byte(params.Channel), uint16(raw)) + } else { + values, err = service.clearPWMAndReadback(request.Context()) } - values, err := service.pwmValues(request.Context()) if err != nil { - writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": err.Error()}) + writeHTTPJSON(writer, http.StatusConflict, map[string]any{"error": err.Error(), "details": dispatchRPCError(err).Data}) return } writeHTTPJSON(writer, http.StatusOK, values) @@ -3983,7 +4039,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", @@ -4324,7 +4381,7 @@ func serveWebSocket( defer cancel() return connection.Write(writeContext, websocket.MessageText, encoded) } - subscriptions := newWebSocketSubscriptions(ctx, service.Client, service.controllerSnapshot, writeJSON) + subscriptions := newWebSocketSubscriptions(ctx, service.Client, service.controllerSnapshot, service.cachedPWMValues, service.HostInstanceID, writeJSON) defer subscriptions.stopAll() for { @@ -4471,7 +4528,7 @@ func serveSocketIO( return } - subscriptions := newWebSocketSubscriptions(ctx, service.Client, service.controllerSnapshot, writeNotification) + subscriptions := newWebSocketSubscriptions(ctx, service.Client, service.controllerSnapshot, service.cachedPWMValues, service.HostInstanceID, writeNotification) defer subscriptions.stopAll() go func() { ticker := time.NewTicker(25 * time.Second) @@ -4905,21 +4962,25 @@ type webSocketSubscriptionWorker struct { // A preserve subscription is deliberately topic-scoped: changing status // cadence cannot cancel and recreate ordered event/state streams or reset cursors. type webSocketSubscriptions struct { - ctx context.Context - client *controller.Client - snapshot func() controllerSnapshotEnvelope - write func(any) error - workers map[string]webSocketSubscriptionWorker + instanceID string + ctx context.Context + client *controller.Client + snapshot func() controllerSnapshotEnvelope + pwm func() *pwmValues + write func(any) error + workers map[string]webSocketSubscriptionWorker } func newWebSocketSubscriptions( ctx context.Context, client *controller.Client, snapshot func() controllerSnapshotEnvelope, + pwm func() *pwmValues, + instanceID string, write func(any) error, ) *webSocketSubscriptions { return &webSocketSubscriptions{ - ctx: ctx, client: client, snapshot: snapshot, write: write, + ctx: ctx, client: client, snapshot: snapshot, pwm: pwm, instanceID: instanceID, write: write, workers: make(map[string]webSocketSubscriptionWorker), } } @@ -4973,15 +5034,18 @@ func (subscriptions *webSocketSubscriptions) start(topic string, value wsSubscri defer close(done) switch topic { case "events": - streamWebSocketEventStream(topicContext, subscriptions.client, afterID, "activity", "controller.event", 0, subscriptions.snapshot, subscriptions.write) + streamWebSocketEventStream(topicContext, subscriptions.client, afterID, "activity", "controller.event", 0, subscriptions.snapshot, nil, subscriptions.write) case "state": - streamWebSocketEventStream(topicContext, subscriptions.client, afterID, "state", "controller.state", time.Duration(value.StateIntervalMS)*time.Millisecond, subscriptions.snapshot, subscriptions.write) + if err := subscriptions.write(wsNotification{JSONRPC: Version, Method: "controller.snapshot", Params: subscriptions.snapshot()}); err != nil { + return + } + streamWebSocketEventStream(topicContext, subscriptions.client, afterID, "state", "controller.state", time.Duration(value.StateIntervalMS)*time.Millisecond, subscriptions.snapshot, subscriptions.pwm, subscriptions.write) case "debug": - streamWebSocketEventStream(topicContext, subscriptions.client, afterID, "debug", "controller.debug", 0, nil, subscriptions.write) + streamWebSocketEventStream(topicContext, subscriptions.client, afterID, "debug", "controller.debug", 0, nil, nil, subscriptions.write) case "opcodes": streamWebSocketOpcodes(topicContext, subscriptions.client, afterID, value.Opcodes, subscriptions.write) case "status": - streamWebSocketStatus(topicContext, subscriptions.client, time.Duration(value.IntervalMS)*time.Millisecond, subscriptions.write) + streamWebSocketStatus(topicContext, subscriptions.client, time.Duration(value.IntervalMS)*time.Millisecond, subscriptions.instanceID, subscriptions.write) } }() } @@ -5019,6 +5083,20 @@ func streamWebSocketOpcodes( } } +type stateFrameBudget map[string]time.Time + +func (budget stateFrameBudget) allow(event controller.Event, interval time.Duration, now time.Time) bool { + if interval <= 0 || !highRateStateEvent(event) { + return true + } + key := strings.ToLower(strings.TrimSpace(event.Kind)) + if last := budget[key]; !last.IsZero() && now.Sub(last) < interval { + return false + } + budget[key] = now + return true +} + func streamWebSocketEventStream( ctx context.Context, client *controller.Client, @@ -5027,46 +5105,40 @@ func streamWebSocketEventStream( method string, highRateInterval time.Duration, snapshot func() controllerSnapshotEnvelope, + pwm func() *pwmValues, write func(any) error, ) { - var lastHighRateWrite time.Time - for ctx.Err() == nil { - event, err := client.NextEventStream(ctx, afterID, "", stream) - if err != nil { - return - } - afterID = event.ID - highRate := stream == "state" && highRateStateEvent(event) - if highRate && highRateInterval > 0 && - !lastHighRateWrite.IsZero() && time.Since(lastHighRateWrite) < highRateInterval { - continue - } - if err := write(wsNotification{ - JSONRPC: Version, - Method: method, - Params: event, - }); err != nil { - return + deliver := func(event controller.Event) error { + if err := write(wsNotification{JSONRPC: Version, Method: method, Params: event}); err != nil { + return err } - if highRate { - lastHighRateWrite = time.Now() + if pwm != nil && event.Kind == "pwm.changed" { + if values := pwm(); values != nil { + if err := write(wsNotification{JSONRPC: Version, Method: "controller.pwm.values", Params: values}); err != nil { + return err + } + } } if snapshot != nil && eventUpdatesClientSnapshot(event) { - if err := write(wsNotification{ - JSONRPC: Version, - Method: "controller.snapshot", - Params: snapshot(), - }); err != nil { - return - } + return write(wsNotification{JSONRPC: Version, Method: "controller.snapshot", Params: snapshot()}) } + return nil } + streamLatestState(ctx, afterID, highRateInterval, + func(ctx context.Context, cursor uint64) (controller.Event, error) { + return client.NextEventStream(ctx, cursor, "", stream) + }, deliver) + } func highRateStateEvent(event controller.Event) bool { switch strings.ToLower(strings.TrimSpace(event.Kind)) { - case "status_led.changed", "pwm.changed", "animation.frame", "front_panel.segment": + case "status_led.changed", "pwm.changed", "animation.frame": return true + case "front_panel.segment": + // Digit/blank/blink edges are already changed-only. Dropping one cannot + // be repaired by interpolation or the slow telemetry subscription. + return false default: return false } @@ -5080,20 +5152,25 @@ func eventUpdatesClientSnapshot(event controller.Event) bool { kind := strings.ToLower(strings.TrimSpace(event.Kind)) switch kind { case "door", "bluetooth", "pwm", "relay", "hot", "reset", "identity", - "front_panel.changed", "settings.changed", "illumination.changed", "peripherals.changed", "programming.lifecycle": + "front_panel.changed", "settings.changed", "illumination.changed", "peripherals.changed", "programming.lifecycle", + "emergency_stop", "output", "motion.changed", "melodies.changed", "config", "port.process.changed": return true } + if kind == "macro" { + return event.Lifecycle == "started" || event.Lifecycle == "completed" || event.Lifecycle == "cancelled" || event.Lifecycle == "failed" + } return strings.HasPrefix(kind, "connection") || strings.HasPrefix(kind, "usb.") || strings.HasPrefix(kind, "program.") || strings.HasPrefix(kind, "rf.learn") || - strings.HasPrefix(kind, "macro.") + (strings.HasPrefix(kind, "macro.") && kind != "macro.step") } func streamWebSocketStatus( ctx context.Context, client *controller.Client, interval time.Duration, + instanceID string, write func(any) error, ) { updates, err := client.SubscribeStatus(ctx, interval) @@ -5116,10 +5193,15 @@ func streamWebSocketStatus( } continue } + observed := client.CachedStatusObservation() + if !observed.HaveStatus || !observed.BoardConnected { + continue + } + observed.HostInstanceID = strings.TrimSpace(instanceID) if write(wsNotification{ JSONRPC: Version, Method: "controller.status", - Params: update, + Params: observed, }) != nil { return } @@ -5142,6 +5224,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/live_values.go b/Tools/Controller/internal/ipcjson/live_values.go new file mode 100644 index 000000000..e014a7925 --- /dev/null +++ b/Tools/Controller/internal/ipcjson/live_values.go @@ -0,0 +1,107 @@ +package ipcjson + +import ( + "context" + controller "pccontroller.local/controller" + "sort" + "strings" + "time" +) + +// Each continuous domain retains one trailing value. Audio, display digits and +// lifecycle edges remain ordered and immediate. A quiet stream still flushes. +func streamLatestState(ctx context.Context, after uint64, interval time.Duration, + read func(context.Context, uint64) (controller.Event, error), write func(controller.Event) error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + incoming := make(chan controller.Event, 1) + go func() { + defer close(incoming) + for ctx.Err() == nil { + event, err := read(ctx, after) + if err != nil { + return + } + after = event.ID + select { + case incoming <- event: + case <-ctx.Done(): + return + } + } + }() + budget := make(stateFrameBudget) + pending := make(map[string]controller.Event) + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() + var wake <-chan time.Time + arm := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + wake = nil + delay := time.Hour + for kind := range pending { + left := time.Until(budget[kind].Add(interval)) + if left < delay { + delay = left + } + } + if len(pending) > 0 { + if delay < 0 { + delay = 0 + } + timer.Reset(delay) + wake = timer.C + } + } + flush := func() bool { + now := time.Now() + ready := make([]controller.Event, 0, len(pending)) + for kind, event := range pending { + if budget.allow(event, interval, now) { + ready = append(ready, event) + delete(pending, kind) + } + } + sort.Slice(ready, func(i, j int) bool { return ready[i].ID < ready[j].ID }) + for _, event := range ready { + if write(event) != nil { + return false + } + } + arm() + return true + } + for { + select { + case <-ctx.Done(): + return + case <-wake: + if !flush() { + return + } + case event, ok := <-incoming: + if !ok { + return + } + key := strings.ToLower(strings.TrimSpace(event.Kind)) + if interval > 0 && highRateStateEvent(event) && !budget.allow(event, interval, time.Now()) { + pending[key] = event + arm() + continue + } + delete(pending, key) + if write(event) != nil { + return + } + arm() + } + } +} diff --git a/Tools/Controller/internal/ipcjson/live_values_test.go b/Tools/Controller/internal/ipcjson/live_values_test.go new file mode 100644 index 000000000..af3b8a2c0 --- /dev/null +++ b/Tools/Controller/internal/ipcjson/live_values_test.go @@ -0,0 +1,144 @@ +package ipcjson + +import ( + "context" + "encoding/json" + "github.com/coder/websocket" + controller "pccontroller.local/controller" + "pccontroller.local/controller/internal/appconfig" + "pccontroller.local/controller/internal/control" + "pccontroller.local/controller/internal/shell" + "testing" + "time" +) + +func TestStateBurstFlushesEachFinalValueWithoutNewEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + input := make(chan controller.Event, 6) + output := make(chan controller.Event, 6) + go streamLatestState(ctx, 0, 40*time.Millisecond, func(ctx context.Context, _ uint64) (controller.Event, error) { + select { + case value := <-input: + return value, nil + case <-ctx.Done(): + return controller.Event{}, ctx.Err() + } + }, func(value controller.Event) error { output <- value; return nil }) + for i, kind := range []string{"status_led.changed", "pwm.changed", "status_led.changed", "pwm.changed", "front_panel.segment", "buzzer.note"} { + input <- controller.Event{ID: uint64(i + 1), Kind: kind} + } + seen := map[uint64]bool{} + for len(seen) < 6 { + select { + case value := <-output: + seen[value.ID] = true + case <-ctx.Done(): + t.Fatalf("final domains missing: %v", seen) + } + } +} + +func TestTwoWebSocketClientsReceiveCompleteCalibratedPWMAndReconnectBaseline(t *testing.T) { + runtime := control.New(control.Options{}) + defer runtime.Close() + client := controller.AttachSharedRuntime(runtime, shell.New(8)) + config := appconfig.Defaults() + config.Integrations.Discovery = appconfig.Discovery{} + config.IPC.UDP.Enabled = false + service := &Service{Client: client, HostInstanceID: "fixture-owner", WebSocketPath: "/ipc", HostConfig: func() appconfig.Config { return config }} + listener, err := Listen("127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + go Serve(ctx, listener, service) + defer listener.Close() + connect := func() (*websocket.Conn, *pwmValues) { + socket, _, err := websocket.Dial(ctx, "ws://"+listener.Addr().String()+"/ipc", nil) + if err != nil { + t.Fatal(err) + } + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"controller.subscribe","params":{"topics":["state"],"state_interval_ms":50}}`) + if err := socket.Write(ctx, websocket.MessageText, body); err != nil { + t.Fatal(err) + } + var baseline *pwmValues + ack, snapshotSeen := false, false + for !ack || !snapshotSeen { + _, body, err := socket.Read(ctx) + if err != nil { + t.Fatal(err) + } + var frame struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if json.Unmarshal(body, &frame) != nil { + t.Fatal("invalid subscription frame") + } + if len(frame.ID) > 0 { + ack = true + } + if frame.Method == "controller.snapshot" { + var state controllerSnapshotEnvelope + if json.Unmarshal(frame.Params, &state) != nil { + t.Fatal("invalid baseline") + } + baseline = state.PWMValues + snapshotSeen = true + } + } + return socket, baseline + } + first, _ := connect() + second, _ := connect() + defer first.CloseNow() + defer second.CloseNow() + values := controller.PWMValues{Available: true, SelectedChannel: 2} + values.Values[2] = 1234 + client.PublishPWMValues(values) + read := func(socket *websocket.Conn, method string) *pwmValues { + for { + _, body, err := socket.Read(ctx) + if err != nil { + t.Fatal(err) + } + var message struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if json.Unmarshal(body, &message) != nil || message.Method != method { + continue + } + if method == "controller.snapshot" { + var snapshot controllerSnapshotEnvelope + if json.Unmarshal(message.Params, &snapshot) != nil { + t.Fatal("invalid snapshot") + } + if snapshot.PWMValues != nil { + return snapshot.PWMValues + } + continue + } + var value pwmValues + if json.Unmarshal(message.Params, &value) != nil { + t.Fatal("invalid values") + } + return &value + } + } + for _, socket := range []*websocket.Conn{first, second} { + value := read(socket, "controller.pwm.values") + if value.HostInstanceID != "fixture-owner" || value.Revision != 1 || value.Values[2] != 1234 || len(value.Channels) != 16 || value.Channels[2].LogicalPercent <= 0 { + t.Fatalf("incomplete push=%+v", value) + } + } + third, value := connect() + defer third.CloseNow() + if value == nil || value.Revision != 1 || value.Values[2] != 1234 { + t.Fatalf("reconnect baseline=%+v", value) + } +} diff --git a/Tools/Controller/internal/ipcjson/pwm_channels.go b/Tools/Controller/internal/ipcjson/pwm_channels.go index 6e07564b9..a52fa01c9 100644 --- a/Tools/Controller/internal/ipcjson/pwm_channels.go +++ b/Tools/Controller/internal/ipcjson/pwm_channels.go @@ -5,7 +5,9 @@ import ( "fmt" "math" "strings" + "time" + controller "pccontroller.local/controller" "pccontroller.local/controller/internal/appconfig" "pccontroller.local/controller/internal/native" ) @@ -19,36 +21,96 @@ type pwmChannelValue struct { } type pwmValues struct { - Available bool `json:"available"` - SelectedChannel byte `json:"selected_channel"` - Values [16]uint16 `json:"values"` - Channels []pwmChannelValue `json:"channels"` + HostInstanceID string `json:"host_instance_id"` + ConnectionGeneration uint64 `json:"connection_generation"` + Revision uint64 `json:"revision"` + Time time.Time `json:"time"` + Available bool `json:"available"` + SelectedChannel byte `json:"selected_channel"` + Values [16]uint16 `json:"values"` + Channels []pwmChannelValue `json:"channels"` } func (service *Service) pwmValues(ctx context.Context) (pwmValues, error) { + ctx, cancel := context.WithTimeout(ctx, pwmOperationTimeout) + defer cancel() values, err := service.Client.PWMValues(ctx) if err != nil { return pwmValues{}, err } - return service.describePWMValues(values), nil + _ = values + if values := service.cachedPWMValues(); values != nil { + return *values, nil + } + return pwmValues{}, fmt.Errorf("PWM readback owner changed") +} + +func (service *Service) cachedPWMValues() *pwmValues { + snapshot := service.Client.Snapshot() + if snapshot.PWMValues == nil { + return nil + } + values := service.describePWMValuesForSnapshot(*snapshot.PWMValues, snapshot) + values.HostInstanceID, values.ConnectionGeneration = strings.TrimSpace(service.HostInstanceID), snapshot.ConnectionGeneration + values.Revision, values.Time = snapshot.PWMRevision, snapshot.PWMUpdated + return &values +} + +// Write and readback share a budget below native clients' 3-second socket +// deadline. Never retry a write whose acknowledgement may have been lost. +const pwmOperationTimeout = 2 * time.Second + +type pwmConfirmationError struct{ cause error } + +func (err *pwmConfirmationError) Error() string { + return "PWM change acknowledged, but readback was not received. Refresh hardware values; the change was not retried." +} +func (err *pwmConfirmationError) Unwrap() error { return err.cause } + +func changePWMAndReadback(ctx context.Context, change func(context.Context) error, read func(context.Context) (pwmValues, error)) (pwmValues, error) { + ctx, cancel := context.WithTimeout(ctx, pwmOperationTimeout) + defer cancel() + if err := change(ctx); err != nil { + return pwmValues{}, err + } + values, err := read(ctx) + if err != nil { + return pwmValues{}, &pwmConfirmationError{cause: err} + } + return values, nil +} + +func (service *Service) setPWMAndReadback(ctx context.Context, channel byte, raw uint16) (pwmValues, error) { + if err := service.rejectLockedPeripheral(fmt.Sprintf("pwm.%d", channel)); err != nil { + return pwmValues{}, err + } + return changePWMAndReadback(ctx, func(ctx context.Context) error { + return service.Client.SetPWMChannel(ctx, channel, raw) + }, service.pwmValues) +} + +func (service *Service) clearPWMAndReadback(ctx context.Context) (pwmValues, error) { + return changePWMAndReadback(ctx, service.Client.AllPWMOff, service.pwmValues) } func (service *Service) describePWMValues(values native.PWMValues) pwmValues { + return service.describePWMValuesForSnapshot(values, service.Client.Snapshot()) +} + +func (service *Service) describePWMValuesForSnapshot(values native.PWMValues, snapshot controller.Snapshot) pwmValues { config := service.hostConfig() result := pwmValues{ Available: values.Available, SelectedChannel: values.SelectedChannel, Values: values.Values, Channels: make([]pwmChannelValue, 0, appconfig.PWMChannelCount), } + _, profile, _ := boardProfileForSnapshot(config, snapshot) + names := appconfig.PWMDisplayNames(config.UI, profile.Presentation) for channel, raw := range values.Values { channelConfig := appconfig.PWMChannel(config.UI, channel) - name, _ := appconfig.PeripheralDefaultName(appconfig.PWMChannelKey(channel)) - if configured := config.UI.PeripheralNames[appconfig.PWMChannelKey(channel)]; strings.TrimSpace(configured) != "" { - name = strings.TrimSpace(configured) - } result.Channels = append(result.Channels, pwmChannelValue{ Channel: channel, RawValue: raw, LogicalPercent: math.Round(appconfig.PWMPercentFromRaw(raw, channelConfig)*1000) / 1000, - Name: name, Config: channelConfig, + Name: names[channel], Config: channelConfig, }) } return result diff --git a/Tools/Controller/internal/ipcjson/pwm_deadline_test.go b/Tools/Controller/internal/ipcjson/pwm_deadline_test.go new file mode 100644 index 000000000..5182b4efb --- /dev/null +++ b/Tools/Controller/internal/ipcjson/pwm_deadline_test.go @@ -0,0 +1,52 @@ +package ipcjson + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestPWMWriteAndReadbackShareDeadlineWithoutRetry(t *testing.T) { + parent, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + writes := 0 + var writeDeadline time.Time + _, err := changePWMAndReadback(parent, func(ctx context.Context) error { + writes++ + writeDeadline, _ = ctx.Deadline() + return nil + }, func(ctx context.Context) (pwmValues, error) { + readDeadline, _ := ctx.Deadline() + if readDeadline != writeDeadline { + t.Fatal("readback received a fresh timeout budget") + } + <-ctx.Done() + return pwmValues{}, ctx.Err() + }) + var confirmation *pwmConfirmationError + if writes != 1 || !errors.As(err, &confirmation) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("write count=%d error=%v", writes, err) + } + rpc := dispatchRPCError(err) + data := rpc.Data.(map[string]any) + if data["kind"] != "confirmation_unavailable" || data["write_acknowledged"] != true || data["retryable"] != false { + t.Fatalf("lost acknowledgement evidence: %#v", data) + } +} + +func TestPWMUnacknowledgedWriteDoesNotReadOrRetry(t *testing.T) { + writes, reads := 0, 0 + _, err := changePWMAndReadback(context.Background(), func(ctx context.Context) error { + writes++ + deadline, _ := ctx.Deadline() + if time.Until(deadline) > pwmOperationTimeout { + t.Fatal("unbounded PWM write") + } + return context.DeadlineExceeded + }, func(context.Context) (pwmValues, error) { reads++; return pwmValues{}, nil }) + var confirmation *pwmConfirmationError + if writes != 1 || reads != 0 || errors.As(err, &confirmation) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("writes=%d reads=%d error=%v", writes, reads, err) + } +} diff --git a/Tools/Controller/internal/ipcjson/pwm_projection_test.go b/Tools/Controller/internal/ipcjson/pwm_projection_test.go new file mode 100644 index 000000000..042537efa --- /dev/null +++ b/Tools/Controller/internal/ipcjson/pwm_projection_test.go @@ -0,0 +1,27 @@ +package ipcjson + +import ( + "testing" + + "pccontroller.local/controller/internal/appconfig" + "pccontroller.local/controller/internal/native" +) + +func TestPWMProjectionUsesOneConfigReadAndPreservesCalibration(t *testing.T) { + service, config := browserUIConfigTestService(t) + config.UI.PeripheralNames["pwm.1"] = "Window" + reads := 0 + service.HostConfig = func() appconfig.Config { reads++; return *config } + raw := native.PWMValues{Available: true, SelectedChannel: 1} + raw.Values[1] = 2048 + values := service.describePWMValuesForSnapshot(raw, service.Client.Snapshot()) + if reads != 1 { + t.Fatalf("PWM frame allocated %d configuration views, want one", reads) + } + if values.Channels[1].Name != "Window" || values.Channels[1].RawValue != 2048 || values.Values != raw.Values { + t.Fatalf("projection lost authoritative values/name: %+v", values.Channels[1]) + } + if values.Channels[1].LogicalPercent <= 0 || values.Channels[1].LogicalPercent >= 100 { + t.Fatal("projection lost calibrated non-endpoint PWM percentage") + } +} diff --git a/Tools/Controller/internal/ipcjson/state_frame_budget_test.go b/Tools/Controller/internal/ipcjson/state_frame_budget_test.go new file mode 100644 index 000000000..a0e78464c --- /dev/null +++ b/Tools/Controller/internal/ipcjson/state_frame_budget_test.go @@ -0,0 +1,24 @@ +package ipcjson + +import ( + "testing" + "time" + + controller "pccontroller.local/controller" +) + +func TestStateFrameBudgetsDoNotDropSegmentOrBuzzerEdges(t *testing.T) { + budget := make(stateFrameBudget) + now := time.Unix(1700000000, 0) + for _, kind := range []string{"status_led.changed", "front_panel.segment", "pwm.changed", "buzzer.note", "front_panel.segment"} { + if !budget.allow(controller.Event{Kind: kind}, 16*time.Millisecond, now) { + t.Fatalf("independent edge %s was lost", kind) + } + } + if budget.allow(controller.Event{Kind: "status_led.changed"}, 16*time.Millisecond, now.Add(time.Millisecond)) { + t.Fatal("RGB frame budget was not retained") + } + if !budget.allow(controller.Event{Kind: "status_led.changed"}, 16*time.Millisecond, now.Add(16*time.Millisecond)) { + t.Fatal("RGB frame budget did not reopen") + } +} diff --git a/Tools/Controller/internal/ipcjson/state_subscription_test.go b/Tools/Controller/internal/ipcjson/state_subscription_test.go index e3ee6a94e..c8de0ae90 100644 --- a/Tools/Controller/internal/ipcjson/state_subscription_test.go +++ b/Tools/Controller/internal/ipcjson/state_subscription_test.go @@ -24,12 +24,12 @@ func TestNormalizeSubscriptionAcceptsBoundedStatePresentationCadence(t *testing. } func TestHighRateStateEventClassificationLeavesEdgesImmediate(t *testing.T) { - for _, kind := range []string{"status_led.changed", "pwm.changed", "animation.frame", "front_panel.segment"} { + for _, kind := range []string{"status_led.changed", "pwm.changed", "animation.frame"} { if !highRateStateEvent(controllerapi.Event{Kind: kind}) { t.Fatalf("%q must be presentation-rate limited", kind) } } - for _, kind := range []string{"relay.changed", "door", "connection.changed", "settings.changed"} { + for _, kind := range []string{"relay.changed", "door", "connection.changed", "settings.changed", "front_panel.segment", "buzzer.note"} { if highRateStateEvent(controllerapi.Event{Kind: kind}) { t.Fatalf("%q must remain immediate", kind) } diff --git a/Tools/Controller/internal/ipcjson/udp.go b/Tools/Controller/internal/ipcjson/udp.go new file mode 100644 index 000000000..656043245 --- /dev/null +++ b/Tools/Controller/internal/ipcjson/udp.go @@ -0,0 +1,222 @@ +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.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") + } + 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 r.Method == "controller.udp.call" { + if err := ValidateBridgeRequest(params.Request); 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/internal/ipcjson/ui_config_test.go b/Tools/Controller/internal/ipcjson/ui_config_test.go index 4e7e84332..dc7d66350 100644 --- a/Tools/Controller/internal/ipcjson/ui_config_test.go +++ b/Tools/Controller/internal/ipcjson/ui_config_test.go @@ -50,9 +50,23 @@ func TestBrowserUIConfigRPCPersistsOnlyRequestedHostFields(t *testing.T) { if get.Error != nil || !ok || initial.AppTitle != "Workshop Controller" || initial.SetupComplete { t.Fatalf("initial UI config=%#v error=%v", get.Result, get.Error) } - if initial.PeripheralNames["relay.5"] != "Workbench lamp" || len(initial.Peripherals) != 34 || len(initial.Controls) != 26 { + // An unconfigured board advertises its safe generic channels, not cinema + // motion channels or raw seat relays belonging to a configured profile. + if initial.PeripheralNames["relay.5"] != "Workbench lamp" || len(initial.Peripherals) != 32 || len(initial.Controls) != 20 { t.Fatalf("initial peripheral contract names=%#v descriptors=%d controls=%d", initial.PeripheralNames, len(initial.Peripherals), len(initial.Controls)) } + renamedRelay := false + for _, item := range initial.Controls { + if item.Key == "seat.a" || item.Key == "seat.b" || item.Key == "relay.1" { + t.Fatalf("unconfigured board advertised profile-specific control %q", item.Key) + } + if item.Key == "relay.5" { + renamedRelay = item.Name == "Workbench lamp" && item.Kind == "relay" + } + } + if !renamedRelay { + t.Fatal("UI catalog lost the renamed relay") + } if !initial.SegmentScroll.Enabled || len(initial.SegmentScroll.Pages) != 1 || initial.SegmentScroll.Pages[0] != "door" { t.Fatalf("initial segment scroll config=%+v", initial.SegmentScroll) } @@ -237,6 +251,29 @@ func TestPeripheralCatalogUsesExplicitBoardProfileWithoutLegacyMotionAliases(t * } } +func TestBrowserAndPWMNamesFollowTheBoardProfile(t *testing.T) { + service, config := browserUIConfigTestService(t) + config.Connection.LastDevice = &appconfig.DeviceIdentity{Port: "COM18", SerialNumber: "BOARD-42"} + config.BoardProfiles = map[string]appconfig.BoardProfile{ + "serial:board-42": {Key: "cinema", Mode: appconfig.BoardModeCinemaSeatMotion, + Presentation: map[string]appconfig.PeripheralPresentation{"pwm.0": {Name: "Window"}}}, + } + settings := service.browserUISettings() + found := false + for _, control := range settings.Controls { + if control.Key == "pwm.0" { + found = control.Name == "Window" + } + } + if !found { + t.Fatal("browser catalog ignored the board-profile name") + } + values := service.describePWMValues(native.PWMValues{Available: true}) + if values.Channels[0].Name != "Window" { + t.Fatalf("PWM channel name = %q", values.Channels[0].Name) + } +} + func TestPresentationUpdateResultCarriesTheAuthoritativeControl(t *testing.T) { settings := peripheralSettings{ BoardProfile: boardProfileDescriptor{Key: "cafe-cinema", Revision: "next-revision"}, 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/internal/link/request_admission_test.go b/Tools/Controller/internal/link/request_admission_test.go index 6392153d4..4357dfc24 100644 --- a/Tools/Controller/internal/link/request_admission_test.go +++ b/Tools/Controller/internal/link/request_admission_test.go @@ -67,6 +67,50 @@ func waitForQueuedRequests(t *testing.T, admission *requestAdmission, count int) t.Fatalf("request waiters did not reach %d", count) } +func TestNormalControlOvertakesQueuedStripWork(t *testing.T) { + var admission requestAdmission + closing := make(chan struct{}) + releaseActive, _ := admission.acquire(context.Background(), closing, RequestPriorityNormal) + background := make(chan func(), 1) + normal := make(chan func(), 1) + go func() { + release, _ := admission.acquire(context.Background(), closing, RequestPriorityBackground) + background <- release + }() + waitForQueuedRequests(t, &admission, 1) + go func() { + release, _ := admission.acquire(context.Background(), closing, RequestPriorityNormal) + normal <- release + }() + waitForQueuedRequests(t, &admission, 2) + releaseActive() + select { + case release := <-normal: + release() + case <-time.After(time.Second): + t.Fatal("control request did not overtake queued strip work") + } + select { + case release := <-background: + release() + case <-time.After(time.Second): + t.Fatal("strip slot was stranded") + } +} + +func TestLostStripACKReleasesWireBeforeStatusDeadline(t *testing.T) { + port := newFakePort() + var writes atomic.Int32 + port.onWrite = func([]byte) { writes.Add(1) } + session := NewForPort("TEST", port) + defer session.Close() + started := time.Now() + err := session.Command(context.Background(), native.OpAddressableLED, []byte{0xFC}) + if !errors.Is(err, context.DeadlineExceeded) || time.Since(started) >= 1500*time.Millisecond || writes.Load() != 1 { + t.Fatalf("strip response bound/replay failed: elapsed=%s writes=%d err=%v", time.Since(started), writes.Load(), err) + } +} + func TestTimelineRequestOvertakesQueuedNormalRequest(t *testing.T) { var admission requestAdmission closing := make(chan struct{}) diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 674151f3a..c58df1ff7 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -20,6 +20,8 @@ import ( const ( DefaultBaudRate = 115200 DefaultReadTimeout = 100 * time.Millisecond + // A lost strip ACK must not hold the sole wire slot beyond status refresh. + stripResponseTimeout = 750 * time.Millisecond ) var ( @@ -97,7 +99,8 @@ type pendingRequest struct { type RequestPriority uint8 const ( - RequestPriorityNormal RequestPriority = iota + RequestPriorityBackground RequestPriority = iota + RequestPriorityNormal RequestPriorityTimeline ) @@ -443,11 +446,22 @@ func (s *Session) RequestWithPriority( return native.Frame{}, err } waitStarted := time.Now() + if opcode == native.OpAddressableLED && priority == RequestPriorityNormal { + priority = RequestPriorityBackground + } releaseRequest, err := s.requestAdmission.acquire(ctx, s.closing, priority) if err != nil { return native.Frame{}, s.requestSlotError(opcode, waitStarted, err) } defer releaseRequest() + if opcode == native.OpAddressableLED { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, stripResponseTimeout) + defer cancel() + } + if err := ctx.Err(); err != nil { + return native.Frame{}, err + } sequence, waiter, err := s.reserveSequence(opcode, expected) if err != nil { return native.Frame{}, err 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/Controller/internal/startupmode/mode.go b/Tools/Controller/internal/startupmode/mode.go new file mode 100644 index 000000000..bc131b1a3 --- /dev/null +++ b/Tools/Controller/internal/startupmode/mode.go @@ -0,0 +1,67 @@ +// Package startupmode classifies presentation only, without configuration, +// discovery, transport initialization or output mutation. +package startupmode + +import "strings" + +func Command(args []string) string { + remaining := make([]string, 0, len(args)) + for index := 0; index < len(args); index++ { + value := args[index] + global := false + for _, flag := range []string{"--config", "--app-name", "--tagline", "--app-tagline"} { + if value == flag { + if index+1 >= len(args) { + return "invalid" + } + index++ + if strings.TrimSpace(args[index]) == "" { + return "invalid" + } + global = true + break + } + if strings.HasPrefix(value, flag+"=") { + if strings.TrimSpace(strings.TrimPrefix(value, flag+"=")) == "" { + return "invalid" + } + global = true + break + } + } + if !global { + remaining = append(remaining, value) + } + } + if len(remaining) == 0 { + return "tui" + } + return strings.ToLower(remaining[0]) +} + +func TUI(args []string) bool { return Command(args) == "tui" } + +// BackgroundWeb disables the terminal explicitly. Browser/tray preferences +// do not disable the TUI: even a --no-open --no-tray host has a terminal UI. +func BackgroundWeb(args []string) bool { + if Command(args) != "web" { + return false + } + var noTUI bool + for index := 0; index < len(args); index++ { + flag := args[index] + if flag == "--config" || flag == "--app-name" || flag == "--tagline" || flag == "--app-tagline" { + index++ + continue + } + switch flag { + case "--help", "-h": + return false + case "--no-tui", "--no-tui=true": + noTUI = true + case "--no-tui=false": + noTUI = false + } + } + return noTUI +} diff --git a/Tools/Controller/internal/startupmode/mode_test.go b/Tools/Controller/internal/startupmode/mode_test.go new file mode 100644 index 000000000..c45b9f4b1 --- /dev/null +++ b/Tools/Controller/internal/startupmode/mode_test.go @@ -0,0 +1,37 @@ +package startupmode + +import "testing" + +func TestTUIModeExcludesMachineReadableAndBackgroundCommands(t *testing.T) { + for _, args := range [][]string{nil, {"tui"}, {"--config", "custom.json", "tui"}, {"--app-name=Desk", "tui", "--ipc-addr", "127.0.0.1:8787"}} { + if !TUI(args) { + t.Fatalf("TUI rejected %q", args) + } + } + for _, args := range [][]string{{"version"}, {"web"}, {"__self-update-helper", "journal.json"}, {"ipc", "serve"}, {"--config", "x.json", "exec", "status"}, {"--config"}, {"--config="}, {"--app-name="}} { + if TUI(args) { + t.Fatalf("splash would contaminate %q", args) + } + } +} + +func TestBackgroundWebRequiresExplicitNoTUI(t *testing.T) { + for _, test := range []struct { + args []string + want bool + }{ + {[]string{"web", "--no-open", "--no-tray"}, false}, + {[]string{"--config", "custom.json", "web", "--no-tui=true"}, true}, + {[]string{"web", "--no-tui"}, true}, + {[]string{"web", "--no-tui", "--no-tui=false"}, false}, + {[]string{"web", "--no-open"}, false}, + {[]string{"web", "--no-open", "--no-tray", "--no-tray=false"}, false}, + {[]string{"web", "--no-open", "--no-tray", "--help"}, false}, + {[]string{"tui", "--no-open", "--no-tray"}, false}, + {[]string{"--config", "--no-open", "web", "--no-tray"}, false}, + } { + if got := BackgroundWeb(test.args); got != test.want { + t.Fatalf("args=%q background=%v want %v", test.args, got, test.want) + } + } +} diff --git a/Tools/Controller/internal/tui/event_coalescing_test.go b/Tools/Controller/internal/tui/event_coalescing_test.go index 8bdb8e803..2746c0c68 100644 --- a/Tools/Controller/internal/tui/event_coalescing_test.go +++ b/Tools/Controller/internal/tui/event_coalescing_test.go @@ -27,11 +27,23 @@ func TestWaitControlEventLetsActivityPreemptStatePresentationWindow(t *testing.T started := time.Now() message := waitControlEvent(events)() - event, ok := message.(runtimeEventMsg) - if !ok || control.Event(event).Kind != "door.changed" { + eventsBatch, ok := message.(runtimeEventBatchMsg) + if !ok || len(eventsBatch) != 2 || eventsBatch[0].Kind != "status_led.changed" || eventsBatch[1].Kind != "door.changed" { t.Fatalf("message=%#v, want activity event", message) } if elapsed := time.Since(started); elapsed >= 40*time.Millisecond { t.Fatalf("activity was delayed by state presentation window: %s", elapsed) } } + +func TestWaitControlEventRetainsEveryContinuousDomain(t *testing.T) { + events := make(chan control.Event, 3) + events <- control.Event{ID: 1, Kind: "status_led.changed", Stream: control.EventStreamState} + events <- control.Event{ID: 2, Kind: "pwm.changed", Stream: control.EventStreamState} + events <- control.Event{ID: 3, Kind: "status_led.changed", Stream: control.EventStreamState} + close(events) + batch, ok := waitControlEvent(events)().(runtimeEventBatchMsg) + if !ok || len(batch) != 2 || batch[0].ID != 2 || batch[1].ID != 3 { + t.Fatalf("domain values lost: %+v", batch) + } +} diff --git a/Tools/Controller/internal/tui/model.go b/Tools/Controller/internal/tui/model.go index 79c28a0bc..0661f64b4 100644 --- a/Tools/Controller/internal/tui/model.go +++ b/Tools/Controller/internal/tui/model.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "sort" "strconv" "strings" "time" @@ -236,6 +237,8 @@ type tickMsg time.Time type welcomeTickMsg time.Time type welcomeMelodyResultMsg struct{ err error } type runtimeEventMsg control.Event +type runtimeEventReplayMsg control.Event +type runtimeEventBatchMsg []control.Event type controlEventClosedMsg struct{} type commandResultMsg struct { line string @@ -797,8 +800,30 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { model.finishWelcome() } - case runtimeEventMsg: - event := control.Event(message) + case runtimeEventBatchMsg: + for _, event := range message { + updated, cmd := model.Update(runtimeEventReplayMsg(event)) + model = updated.(Model) + if cmd != nil { + commands = append(commands, cmd) + } + } + if model.remote != nil && model.remote.Events != nil && !model.remoteEventsClosed { + commands = append(commands, waitControlEvent(model.remote.Events)) + } else if model.runtime != nil { + commands = append(commands, waitControlEvent(model.runtime.Events())) + } + return model, tea.Batch(commands...) + case runtimeEventMsg, runtimeEventReplayMsg: + var event control.Event + replay := false + switch value := message.(type) { + case runtimeEventMsg: + event = control.Event(value) + case runtimeEventReplayMsg: + event = control.Event(value) + replay = true + } if strings.HasPrefix(event.Kind, "macro") && model.remote != nil && model.remote.Snapshot != nil && !model.remoteSnapshotPending { model.remoteSnapshotPending = true commands = append(commands, refreshRemoteSnapshot(model.remote.Snapshot, model.remoteStatusSequence, model.remoteLEDSequence)) @@ -883,12 +908,14 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if show { model.appendLog(event.Kind, event.Text) } - if model.remote != nil { - if model.remote.Events != nil && !model.remoteEventsClosed { - commands = append(commands, waitControlEvent(model.remote.Events)) + if !replay { + if model.remote != nil { + if model.remote.Events != nil && !model.remoteEventsClosed { + commands = append(commands, waitControlEvent(model.remote.Events)) + } + } else if model.runtime != nil { + commands = append(commands, waitControlEvent(model.runtime.Events())) } - } else { - commands = append(commands, waitControlEvent(model.runtime.Events())) } case controlEventClosedMsg: @@ -903,6 +930,25 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { case remoteLiveUpdateMsg: update := RemoteLiveUpdate(message) + if update.Snapshot != nil && update.Snapshot.ConnectionGeneration >= model.remoteSnapshot.ConnectionGeneration { + incoming := *update.Snapshot + if incoming.ConnectionGeneration == model.remoteSnapshot.ConnectionGeneration { + incoming = mergeRemoteSnapshot(model.remoteSnapshot, incoming, !incoming.StatusUpdated.Before(model.remoteSnapshot.StatusUpdated), true) + if incoming.PWMRevision < model.remoteSnapshot.PWMRevision { + incoming.PWMValues, incoming.PWMRevision, incoming.PWMUpdated = model.remoteSnapshot.PWMValues, model.remoteSnapshot.PWMRevision, model.remoteSnapshot.PWMUpdated + } + } + model.remoteSnapshot = incoming + if incoming.HaveStatus { + model.remoteStatusReceivedAt = time.Now() + } + } + if update.PWMValues != nil && update.ConnectionGeneration == model.remoteSnapshot.ConnectionGeneration && update.PWMRevision > model.remoteSnapshot.PWMRevision { + model.remoteSnapshot.PWMValues, model.remoteSnapshot.PWMRevision, model.remoteSnapshot.PWMUpdated = update.PWMValues, update.PWMRevision, update.PWMUpdated + } + if update.ConnectionGeneration != 0 && update.ConnectionGeneration != model.remoteSnapshot.ConnectionGeneration { + update.HaveStatus, update.HaveStatusLED, update.StatusLEDOrderKnown, update.ConnectionChange = false, false, false, false + } if update.ConnectionChange { wasConnected := model.remoteLiveConnected model.remoteLiveConnected = update.Connected @@ -2593,32 +2639,42 @@ func waitControlEvent(events <-chan control.Event) tea.Cmd { if !ok { return controlEventClosedMsg{} } - if event.Stream != control.EventStreamState { + continuous := func(event control.Event) bool { + return event.Stream == control.EventStreamState && (event.Kind == "status_led.changed" || event.Kind == "pwm.changed" || event.Kind == "animation.frame") + } + if !continuous(event) { return runtimeEventMsg(event) } - - // Firmware animation and physical mirrors can publish state at 50/60 FPS. - // A terminal cannot usefully present every intermediate frame, and asking - // Bubble Tea to recompose the full screen for each one can consume a core. - // Keep the newest state frame in a short presentation window while letting - // actionable activity preempt the window immediately. This changes only - // TUI painting; board timing and WebSocket delivery retain full cadence. - latest := event + latest := map[string]control.Event{event.Kind: event} + batch := func(edge *control.Event) tea.Msg { + values := make([]control.Event, 0, len(latest)+1) + for _, value := range latest { + values = append(values, value) + } + sort.Slice(values, func(i, j int) bool { return values[i].ID < values[j].ID }) + if edge != nil { + values = append(values, *edge) + } + if len(values) == 1 { + return runtimeEventMsg(values[0]) + } + return runtimeEventBatchMsg(values) + } timer := time.NewTimer(250 * time.Millisecond) defer timer.Stop() for { select { case <-timer.C: - return runtimeEventMsg(latest) + return batch(nil) case next, open := <-events: if !open { - return runtimeEventMsg(latest) + return batch(nil) } - if next.Stream == control.EventStreamState { - latest = next + if continuous(next) { + latest[next.Kind] = next continue } - return runtimeEventMsg(next) + return batch(&next) } } } diff --git a/Tools/Controller/internal/tui/types.go b/Tools/Controller/internal/tui/types.go index 861e49c22..87e122e17 100644 --- a/Tools/Controller/internal/tui/types.go +++ b/Tools/Controller/internal/tui/types.go @@ -183,20 +183,25 @@ type FrontPanelState struct { // path so a slow terminal always renders the newest state instead of building // an unbounded animation backlog. type RemoteLiveUpdate struct { - Status native.Status - HaveStatus bool - StatusUpdated time.Time - StatusReceivedAt time.Time - StatusLED native.StatusLEDState - HaveStatusLED bool - StatusLEDUpdated time.Time - StatusLEDReceivedAt time.Time - StatusLEDOrderKnown bool - StatusLEDEpoch uint64 - StatusLEDRevision uint64 - ConnectionChange bool - Connected bool - Error string + Snapshot *control.Snapshot + PWMValues *native.PWMValues + PWMRevision uint64 + PWMUpdated time.Time + ConnectionGeneration uint64 + Status native.Status + HaveStatus bool + StatusUpdated time.Time + StatusReceivedAt time.Time + StatusLED native.StatusLEDState + HaveStatusLED bool + StatusLEDUpdated time.Time + StatusLEDReceivedAt time.Time + StatusLEDOrderKnown bool + StatusLEDEpoch uint64 + StatusLEDRevision uint64 + ConnectionChange bool + Connected bool + Error string } // RemoteBackend supplies the live board state and activity stream for a TUI 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/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" diff --git a/Tools/Controller/web/src/advanced-workbench.tsx b/Tools/Controller/web/src/advanced-workbench.tsx index c36d71125..7be5ee081 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' @@ -111,6 +112,7 @@ interface DiscoveryConfig { ws_discovery_enabled: boolean broadcast_enabled: boolean netbios_enabled: boolean + mndp_enabled: boolean broadcast_port: number instance_name: string } @@ -272,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) @@ -348,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(() => { @@ -461,6 +465,7 @@ export function AdvancedWorkbench({ ws_discovery: discoverWS, broadcast: discoverBroadcast, netbios: discoverNetBIOS, + mndp: discoverMNDP, }) setNetworkDevices(value) setServiceOutput(JSON.stringify(value, null, 2)) @@ -482,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(), }) @@ -928,7 +934,9 @@ export function AdvancedWorkbench({ + +
setDiscoveryInstanceName(event.target.value)} /> setDiscoveryBroadcastPort(boundedInteger(event.target.value, 37889, 1024, 65535))} /> @@ -936,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/Tools/Controller/web/src/api.ts b/Tools/Controller/web/src/api.ts index c722db2db..4c1ef67a9 100644 --- a/Tools/Controller/web/src/api.ts +++ b/Tools/Controller/web/src/api.ts @@ -1,5 +1,6 @@ import type { CommandResult, + PWMValues, ControllerEvent, Snapshot, StatusUpdate, @@ -222,6 +223,7 @@ export function execute(command: string, signal?: AbortSignal): Promise void status: (value: StatusUpdate, source: StreamSource) => void event: (value: ControllerEvent, source: StreamSource) => void snapshot?: (value: Snapshot, source: StreamSource) => void @@ -339,6 +341,8 @@ export function connectStream(config: UIConfig, handlers: StreamHandlers): Strea let subscriptionAcknowledged = false const pendingStateEvents: ControllerEvent[] = [] let pendingSnapshot: Snapshot | null = null + let pendingPWM: PWMValues | null = null + let pendingStatus: StatusUpdate | null = null const source = (): StreamSource => ({ generation: currentGeneration, ...(sourceInstanceID ? { instanceID: sourceInstanceID } : {}), @@ -399,14 +403,23 @@ export function connectStream(config: UIConfig, handlers: StreamHandlers): Strea deliverSnapshot(pendingSnapshot) pendingSnapshot = null } + if (pendingPWM) { handlers.pwm?.(pendingPWM, source()); pendingPWM = null } + if (pendingStatus) { handlers.status(pendingStatus, source()); pendingStatus = null } } } - if (value.method === 'controller.status') handlers.status(value.params as StatusUpdate, source()) + if (value.method === 'controller.status') { + if (subscriptionAcknowledged) handlers.status(value.params as StatusUpdate, source()) + else pendingStatus = value.params as StatusUpdate + } + if (value.method === 'controller.pwm.values') { + if (subscriptionAcknowledged) handlers.pwm?.(value.params as PWMValues, source()) + else if (!pendingPWM || Number((value.params as PWMValues).revision) > Number(pendingPWM.revision)) pendingPWM = value.params as PWMValues + } if (value.method === 'controller.event') deliverEvent(value.params as ControllerEvent) if (value.method === 'controller.state') { const event = value.params as ControllerEvent if (subscriptionAcknowledged) deliverEvent(event) - else pendingStateEvents.push(event) + else { if (pendingStateEvents.length >= 256) pendingStateEvents.shift(); pendingStateEvents.push(event) } } if (value.method === 'controller.snapshot') { const snapshot = value.params as Snapshot diff --git a/Tools/Controller/web/src/app.tsx b/Tools/Controller/web/src/app.tsx index f769266dc..27a1109cf 100644 --- a/Tools/Controller/web/src/app.tsx +++ b/Tools/Controller/web/src/app.tsx @@ -74,6 +74,7 @@ import { type TerminalEntry as TabTerminalEntry, } from './tab-channel' import { controllerChannelOrigin } from './transport-config' +import { searchChannels } from './channel-search' import { matchesAppTarget } from './instance-routing' import { loadNavigationSync, @@ -87,6 +88,8 @@ import type { ControllerEvent, DialogState, HostUISettings, + ControlDescriptor, + PeripheralSettings, HistorySample, MetricSample, Snapshot, @@ -101,6 +104,7 @@ import { StatusFrameBatcher, } from './status-frame-batcher' import { advanceStatusLEDSource, applyPushedOutputEvent, isPushedOutputEvent, mergeStatusLEDSnapshot, statusLEDSnapshotMatchesSource, statusLEDSourceUnchanged } from './status-led-event' +import { mergePushedPWM, mergePWMSnapshot } from './pwm-push' import { BuzzerPlaybackTimeline, type BuzzerPath } from './buzzer-routing' import { isMacroControllerEvent, prependMacroControllerEvent } from './macro-live' import { emptySnapshot } from './types' @@ -438,6 +442,8 @@ export default function App() { const [palette, setPalette] = useState(false) const [paletteQuery, setPaletteQuery] = useState('') const [paletteIndex, setPaletteIndex] = useState(0) + const [paletteControls, setPaletteControls] = useState([]) + const [focusControl, setFocusControl] = useState<{key: string; revision: number} | undefined>() const [hotkeyHelp, setHotkeyHelp] = useState(false) const [appPreferencesOpen, setAppPreferencesOpen] = useState(false) const [quickHeader, setQuickHeader] = useState(loadQuickHeaderPreferences) @@ -1209,13 +1215,29 @@ export default function App() { } }, []) + const filteredPalette = navigation.filter((item) => t(item.label).toLowerCase().includes(paletteQuery.toLowerCase())) + const filteredChannels = searchChannels(paletteControls, paletteQuery) + const peripheralChangeID = events.find(event => event.kind === 'peripherals.changed' || event.kind === 'config')?.id + useEffect(() => { + if (!palette || !snapshot.connected || demo) { if (!snapshot.connected) setPaletteControls([]); return } + const abort = new AbortController() + void rpc('controller.peripherals.get', {}, abort.signal) + .then(catalog => { if (!abort.signal.aborted) setPaletteControls(catalog.controls) }) + .catch(cause => { if (!abort.signal.aborted) notify('warning', 'Channel search unavailable', cause instanceof Error ? cause.message : String(cause)) }) + return () => abort.abort() + }, [palette, snapshot.connected, snapshot.port.name, peripheralChangeID]) + const revealChannel = (key: string) => { + setFocusControl(previous => ({ key, revision: (previous?.revision ?? 0) + 1 })) + navigate('controls') + setPalette(false); setPaletteQuery('') + } useEffect(() => { const onKey = (event: KeyboardEvent) => { if (bootOpen || appPreferencesOpen) return const composing = event.isComposing || event.keyCode === 229 if (palette) { if (composing) return - const choices = navigation.filter((item) => t(item.label).toLowerCase().includes(paletteQuery.toLowerCase())) + const choices = [...filteredPalette.map(item => () => navigate(item.id)), ...filteredChannels.map(item => () => revealChannel(item.key))] if (event.key === 'Escape') { event.preventDefault() setPalette(false) @@ -1235,8 +1257,7 @@ export default function App() { } if (event.key === 'Enter' && choices.length) { event.preventDefault() - const selected = choices[Math.min(paletteIndex, choices.length - 1)] - navigate(selected.id) + choices[Math.min(paletteIndex, choices.length - 1)]() setPalette(false) setPaletteQuery('') audioRef.current?.cue('select') @@ -1309,7 +1330,7 @@ export default function App() { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [appPreferencesOpen, bootOpen, dialog.open, hotkeyHelp, mobileNav, navigate, page, palette, paletteIndex, paletteQuery, t, toggleAudio]) + }, [appPreferencesOpen, bootOpen, dialog.open, hotkeyHelp, mobileNav, navigate, page, palette, paletteIndex, paletteQuery, paletteControls, t, toggleAudio]) useEffect(() => { setPaletteIndex(0) @@ -1410,16 +1431,22 @@ export default function App() { setSamples((current) => applyStatusFrameBatch(previous, current, updates).samples) }) stopStream = connectStream(config, { + pwm: (value, source) => { + const next = mergePushedPWM(snapshotRef.current, value, source.instanceID) + if (next !== snapshotRef.current) { snapshotRef.current = next; setSnapshot(next) } + }, status: (update, source) => { + if (update.host_instance_id !== undefined && update.host_instance_id !== snapshotRef.current.host_instance_id) return + if (update.connection_generation !== undefined && update.connection_generation !== snapshotRef.current.connection_generation) { void refresh(); return } statusFrames?.enqueue(source.generation, update) }, snapshot: (value, source) => { const previous = snapshotRef.current - const next = mergeStatusLEDSnapshot(previous, value, { + const next = mergePWMSnapshot(previous, mergeStatusLEDSnapshot(previous, value, { epoch: source.generation, instanceID: value.host_instance_id, authoritativeInstanceID: source.instanceID, - }) + })) snapshotRef.current = next setSnapshot(next) setSamples((current) => metricSamplesAfterSnapshot(current, previous, next)) @@ -1625,6 +1652,7 @@ export default function App() { ? 'loading' : 'unavailable' const shared: SharedViewProps = { + focusControl, reduceMotion: appearance.reduceMotion, appTitle: productTitle, snapshot, samples, events, macroEvents, locale: appearance.locale, t, command: runCommand, refresh, openDialog, boardSettingsReadState, @@ -1655,7 +1683,6 @@ export default function App() { ) const current = navigation.find((item) => item.id === page) ?? navigation[0] - const filteredPalette = navigation.filter((item) => t(item.label).toLowerCase().includes(paletteQuery.toLowerCase())) const closeDialog = () => { if (dialogBusy) return @@ -1859,14 +1886,15 @@ export default function App() { )} {t('quickCommandsLabel').toUpperCase()} + {filteredChannels.map((item, index) => )} {quickCommands.filter(([value, label]) => `${value} ${label}`.toLowerCase().includes(paletteQuery.toLowerCase())).map(([value, label]) => )}
-
{t('navigate')} {t('select')}{filteredPalette.length + quickCommands.length} {t('actions')} · WS {streamState}
+
{t('navigate')} {t('select')}{filteredPalette.length + filteredChannels.length + quickCommands.filter(([value, label]) => `${value} ${label}`.toLowerCase().includes(paletteQuery.toLowerCase())).length} {t('actions')} · WS {streamState}
)} diff --git a/Tools/Controller/web/src/channel-search.test.ts b/Tools/Controller/web/src/channel-search.test.ts new file mode 100644 index 000000000..3f9a963c9 --- /dev/null +++ b/Tools/Controller/web/src/channel-search.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { searchChannels } from './channel-search' +import type { ControlDescriptor } from './types' + +describe('profile-resolved channel search', () => { + const channels: ControlDescriptor[] = [ + { key: 'pwm.0', kind: 'mosfet', order: 0, name: 'Window', group: 'Cinema lighting', control: 'pwm-user' }, + { key: 'seat.a', kind: 'seat', order: 1, name: 'Seat Left', control: 'seat' }, + { key: 'pwm.1', kind: 'mosfet', order: 2, name: 'Hidden lamp', control: 'pwm-user', hidden: true }, + ] + it('finds current names, IDs and groups without changing or actuating channels', () => { + expect(searchChannels(channels, 'window').map(item => item.key)).toEqual(['pwm.0']) + expect(searchChannels(channels, 'pwm.0').map(item => item.name)).toEqual(['Window']) + expect(searchChannels(channels, 'cinema').map(item => item.key)).toEqual(['pwm.0']) + expect(searchChannels(channels, 'seat left').map(item => item.key)).toEqual(['seat.a']) + expect(searchChannels(channels, 'hidden')).toEqual([]) + expect(searchChannels(channels, '')).toEqual([]) + }) +}) diff --git a/Tools/Controller/web/src/channel-search.ts b/Tools/Controller/web/src/channel-search.ts new file mode 100644 index 000000000..9fe19085a --- /dev/null +++ b/Tools/Controller/web/src/channel-search.ts @@ -0,0 +1,9 @@ +import type { ControlDescriptor } from './types' + +/** Search reveals a stable channel; it never actuates it. */ +export function searchChannels(controls: readonly ControlDescriptor[], query: string): ControlDescriptor[] { + const term = query.trim().toLocaleLowerCase(); + if (!term) return []; + return controls.filter(control => !control.hidden && /^(pwm\.\d+|relay\.\d+|seat\.[ab])$/.test(control.key) + && `${control.name} ${control.key} ${control.group ?? ''} ${control.kind}`.toLocaleLowerCase().includes(term)); +} diff --git a/Tools/Controller/web/src/components.tsx b/Tools/Controller/web/src/components.tsx index 58f1ecc58..685617882 100644 --- a/Tools/Controller/web/src/components.tsx +++ b/Tools/Controller/web/src/components.tsx @@ -525,6 +525,8 @@ export function RangeField({ unit, onChange, disabled, + onEditStart, + onEditEnd, }: { label: ReactNode value: number @@ -534,6 +536,8 @@ export function RangeField({ unit?: string onChange: (value: number) => void disabled?: boolean + onEditStart?: () => void + onEditEnd?: () => void }) { const rangeID = useId() const [exactValue, setExactValue] = useState(String(value)) @@ -564,7 +568,8 @@ export function RangeField({ disabled={disabled} aria-label={typeof label === 'string' ? interfaceCopy(`${label} exact value`, `مقدار دقیق ${label}`) : interfaceCopy('Exact value', 'مقدار دقیق')} onInput={(event) => setExactValue(event.currentTarget.value)} - onBlur={commitExactValue} + onFocus={onEditStart} + onBlur={() => { commitExactValue(); onEditEnd?.() }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /> {unit && {unit}} @@ -579,6 +584,13 @@ export function RangeField({ step={step} disabled={disabled} style={{ '--range': `${ratio}%` } as React.CSSProperties} + onPointerDown={onEditStart} + onPointerUp={onEditEnd} + onPointerCancel={onEditEnd} + onLostPointerCapture={onEditEnd} + onBlur={onEditEnd} + onKeyDown={event => { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) onEditStart?.() }} + onKeyUp={onEditEnd} onChange={(event) => onChange(Number(event.target.value))} />
diff --git a/Tools/Controller/web/src/pwm-authority.test.ts b/Tools/Controller/web/src/pwm-authority.test.ts index 729caacd5..ab4c3818c 100644 --- a/Tools/Controller/web/src/pwm-authority.test.ts +++ b/Tools/Controller/web/src/pwm-authority.test.ts @@ -19,6 +19,35 @@ function values(channel: number, value: number): PWMValues { afterEach(() => vi.useRealTimers()) describe('authoritative PWM contract', () => { + it('protects a live gesture between acknowledged writes and flushes on release', async () => { + const scheduler = new PWMMutationScheduler({ + commit: async (channel, value) => values(channel, value), + onDraft: vi.fn(), onAuthoritative: vi.fn(), onPending: vi.fn(), onError: vi.fn(), + }) + scheduler.beginEdit(0) + scheduler.schedule(0, 1000, true) + await Promise.resolve(); await Promise.resolve(); await Promise.resolve() + expect(scheduler.pendingChannels()).toContain(0) + expect(scheduler.isEditing(0)).toBe(true) + scheduler.endEdit(0) + scheduler.dispose() + expect(scheduler.pendingChannels()).toEqual([]) + }) + it('an older failed write cannot roll back a newer desired value', async () => { + let fail: ((reason: Error) => void) | undefined + const onError = vi.fn() + let calls = 0 + const scheduler = new PWMMutationScheduler({ + commit: (channel, value) => ++calls === 1 ? new Promise((_, reject) => { fail = reject }) : Promise.resolve(values(channel, value)), + onDraft: vi.fn(), onAuthoritative: vi.fn(), onPending: vi.fn(), onError, + }) + scheduler.schedule(0, 100, true) + scheduler.schedule(0, 1200, true) + fail?.(new Error('old write failed')) + await Promise.resolve(); await Promise.resolve(); await Promise.resolve() + expect(onError).toHaveBeenCalledWith(0, expect.any(Error), false) + scheduler.dispose() + }) it('maps perceptual lighting brightness without changing linear motor duty', () => { const lighting = { output_type: 'lighting' as const, icon: 'lightbulb', curve: 'gamma' as const, gamma: 2.2 } const motor = { output_type: 'motor' as const, icon: 'settings', curve: 'linear' as const, gamma: 1 } diff --git a/Tools/Controller/web/src/pwm-authority.ts b/Tools/Controller/web/src/pwm-authority.ts index 5d580f07f..e5b43565e 100644 --- a/Tools/Controller/web/src/pwm-authority.ts +++ b/Tools/Controller/web/src/pwm-authority.ts @@ -52,7 +52,7 @@ export function normalizePWMValues(value: PWMValues): PWMValues { return candidate }) const channels = value.channels?.map((channel) => ({ ...channel, config: { ...channel.config } })) - return { available: value.available, selected_channel: value.selected_channel, values, channels } + return { ...value, available: value.available, selected_channel: value.selected_channel, values, channels } } interface DesiredPWMValue { @@ -112,6 +112,7 @@ export class PWMOperationQueue { export const PWM_RECONCILIATION_INTERVAL_MS = 1_500 export interface PWMReconcilerOptions { + eventDriven?: boolean intervalMS?: number operations: PWMOperationQueue canRead: () => boolean @@ -177,7 +178,7 @@ export class PWMReconciler { } } finally { if (this.readAbort === readAbort) this.readAbort = null - this.schedule(this.intervalMS, generation) + if (!this.options.eventDriven) this.schedule(this.intervalMS, generation) } } } @@ -189,7 +190,7 @@ export interface PWMMutationSchedulerOptions { onDraft: (channel: number, value: number) => void onAuthoritative: (values: PWMValues, acknowledgedChannel: number | null) => void onPending: (channels: readonly number[]) => void - onError: (channel: number, cause: Error) => void + onError: (channel: number, cause: Error, revertDraft: boolean) => void } // PWMMutationScheduler keeps at most one write in flight per channel and one @@ -202,6 +203,7 @@ export class PWMMutationScheduler { private readonly desired = new Map() private readonly inFlight = new Set() private readonly timers = new Map>() + private readonly editing = new Set() private revision = 0 private disposed = false @@ -214,7 +216,7 @@ export class PWMMutationScheduler { schedule(channel: number, value: number, immediate = false): void { if (this.disposed) return if (!USER_PWM_CHANNELS.includes(channel)) { - this.options.onError(channel, new Error('Generic PWM controls are limited to user channels 0..10')) + this.options.onError(channel, new Error('Generic PWM controls are limited to user channels 0..10'), true) return } const bounded = clampPWMValue(value) @@ -235,7 +237,24 @@ export class PWMMutationScheduler { } pendingChannels(): number[] { - return [...new Set([...this.desired.keys(), ...this.inFlight])].sort((a, b) => a - b) + return [...new Set([...this.desired.keys(), ...this.inFlight, ...this.editing])].sort((a, b) => a - b) + } + + beginEdit(channel: number): void { + if (this.disposed || !USER_PWM_CHANNELS.includes(channel)) return + this.editing.add(channel) + this.notifyPending() + } + + isEditing(channel: number): boolean { return this.editing.has(channel) } + + endEdit(channel: number): void { + this.editing.delete(channel) + const timer = this.timers.get(channel) + if (timer) clearTimeout(timer) + this.timers.delete(channel) + if (this.desired.has(channel)) void this.flush(channel) + this.notifyPending() } dispose(): void { @@ -243,6 +262,7 @@ export class PWMMutationScheduler { for (const timer of this.timers.values()) clearTimeout(timer) this.timers.clear() this.desired.clear() + this.editing.clear() } private async flush(channel: number): Promise { @@ -262,7 +282,7 @@ export class PWMMutationScheduler { if (this.disposed) return const latest = this.desired.get(channel) if (latest?.revision === requested.revision) this.desired.delete(channel) - this.options.onError(channel, cause instanceof Error ? cause : new Error(String(cause))) + this.options.onError(channel, cause instanceof Error ? cause : new Error(String(cause)), latest?.revision === requested.revision) } finally { this.inFlight.delete(channel) if (!this.disposed && this.desired.has(channel)) { diff --git a/Tools/Controller/web/src/pwm-push.test.ts b/Tools/Controller/web/src/pwm-push.test.ts new file mode 100644 index 000000000..80eb637b9 --- /dev/null +++ b/Tools/Controller/web/src/pwm-push.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { mergePushedPWM, mergePWMSnapshot } from './pwm-push' +import type { PWMValues, Snapshot } from './types' +const snapshot = { connected: true, host_instance_id: 'primary', connection_generation: 2, pwm_revision: 3 } as Snapshot +const push = (): PWMValues => ({ available: true, selected_channel: 0, + host_instance_id: 'primary', connection_generation: 2, revision: 4, time: new Date().toISOString(), + values: Array.from({ length: 16 }, (_, i) => i * 100), + channels: Array.from({ length: 16 }, (_, i) => ({ channel: i, raw_value: i * 100, logical_percent: i * 5, + name: `Output ${i}`, config: { output_type: 'general', icon: 'power', curve: 'linear', gamma: 1 } })) }) +describe('selected primary PWM pushes', () => { + it('retains calibrated percentages and factual zero without another read', () => { + const next = mergePushedPWM(snapshot, push(), 'primary') + expect(next.pwm_revision).toBe(4) + expect(next.pwm_values?.channels?.[0].logical_percent).toBe(0) + expect(next.pwm_values?.channels?.[3].logical_percent).toBe(15) + }) + it('rejects stale, malformed, and previous-owner observations', () => { + for (const patch of [{ revision: 3 }, { connection_generation: 1 }, { host_instance_id: 'other' }, { channels: [] }]) + expect(mergePushedPWM(snapshot, { ...push(), ...patch }, 'primary')).toBe(snapshot) + }) + it('protects newer pushes from an older snapshot but drops them across board ownership', () => { + const next = mergePushedPWM(snapshot, push(), 'primary') + expect(mergePWMSnapshot(next, snapshot).pwm_revision).toBe(4) + expect(mergePWMSnapshot(next, { ...snapshot, connection_generation: 3 }).pwm_revision).toBe(3) + }) +}) diff --git a/Tools/Controller/web/src/pwm-push.ts b/Tools/Controller/web/src/pwm-push.ts new file mode 100644 index 000000000..6a48f0050 --- /dev/null +++ b/Tools/Controller/web/src/pwm-push.ts @@ -0,0 +1,27 @@ +import type { PWMValues, Snapshot } from './types' +import { normalizePWMValues } from './pwm-authority' + +export function mergePushedPWM(snapshot: Snapshot, value: PWMValues, instanceID?: string): Snapshot { + if (!snapshot.connected || !instanceID || instanceID !== snapshot.host_instance_id || + value.host_instance_id !== instanceID || value.connection_generation !== snapshot.connection_generation || + !Number.isSafeInteger(value.revision) || value.revision! <= (snapshot.pwm_revision ?? 0) || + !value.time || !Number.isFinite(Date.parse(value.time)) || + !Array.isArray(value.channels) || value.channels.length !== 16 || + new Set(value.channels.map(c => c.channel)).size !== 16 || + value.channels.some(c => !Number.isInteger(c.channel) || c.channel < 0 || c.channel > 15 || + !Number.isInteger(c.raw_value) || c.raw_value < 0 || c.raw_value > 4095 || + !Number.isFinite(c.logical_percent) || c.logical_percent < 0 || c.logical_percent > 100)) return snapshot + try { + const values = normalizePWMValues(value) + return { ...snapshot, pwm_values: values, pwm_revision: value.revision, pwm_updated: value.time } + } catch { return snapshot } +} + +export function mergePWMSnapshot(current: Snapshot, incoming: Snapshot): Snapshot { + if (incoming.connected && current.host_instance_id === incoming.host_instance_id && + current.connection_generation === incoming.connection_generation && + (current.pwm_revision ?? 0) > (incoming.pwm_revision ?? 0)) { + return { ...incoming, pwm_values: current.pwm_values, pwm_revision: current.pwm_revision, pwm_updated: current.pwm_updated } + } + return incoming +} diff --git a/Tools/Controller/web/src/status-led-event.ts b/Tools/Controller/web/src/status-led-event.ts index 838ed2580..a8b8664ea 100644 --- a/Tools/Controller/web/src/status-led-event.ts +++ b/Tools/Controller/web/src/status-led-event.ts @@ -167,6 +167,8 @@ export function applyPushedOutputEvent( event: ControllerEvent, source: StatusLEDSource = { epoch: 0 }, ): Snapshot { + const generation = event.metadata?.connection_generation + if (generation !== undefined && Number(generation) !== snapshot.connection_generation) return snapshot const led = statusLEDFromEvent(event) if (led) return applyStatusLEDEvent(snapshot, event, source) const segment = segmentStateFromEvent(event) diff --git a/Tools/Controller/web/src/styles.css b/Tools/Controller/web/src/styles.css index a66f3f4c5..aedab0232 100644 --- a/Tools/Controller/web/src/styles.css +++ b/Tools/Controller/web/src/styles.css @@ -678,7 +678,9 @@ kbd { padding: 3px 6px; border: 1px solid var(--line-strong); border-bottom-colo .relay-switch.is-active .relay-switch__toggle > i b { top: 8px; background: linear-gradient(var(--green), color-mix(in srgb, var(--green) 55%, var(--surface-solid))); box-shadow: 0 0 13px color-mix(in srgb, var(--green) 45%, transparent); } .relay-switch__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; width: 100%; } .motion-actions { display: grid; gap: 8px; margin: 14px 0; } -.motion-side { display: grid; grid-template-columns: minmax(80px, 1fr) repeat(3, auto); gap: 7px; align-items: center; padding: 9px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-muted); } +.motion-side { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(0, 1.4fr); gap: 7px; align-items: center; padding: 9px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-muted); } +.motion-side__actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; align-items: stretch; } +.motion-side__actions > .button { min-width: 0; width: 100%; height: 100%; } .motion-side strong { font: 600 9px var(--font-mono); color: var(--text-muted); } .motion-side__identity { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; } .channel-selector { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; } @@ -1114,7 +1116,7 @@ kbd { padding: 3px 6px; border: 1px solid var(--line-strong); border-bottom-colo .hero-panel { min-height: 230px; grid-template-columns: 1fr; padding: 23px 20px; }.hero-panel__readout { min-width: 0; padding: 10px 0 0; border-inline-start: 0; border-top: 1px solid var(--line); } .metric-grid { grid-template-columns: 1fr 1fr; gap: 9px; }.metric { min-height: 152px; padding-inline: 13px; }.metric__value strong { font-size: 21px; }.metric__detail { display: none; } .telemetry-chart__toolbar { align-items: stretch; flex-direction: column; }.telemetry-chart__toolbar .segmented { width: 100%; }.telemetry-chart__toolbar .segmented button { flex: 1; }.telemetry-chart__canvas { height: 270px; } - .output-matrix, .relay-bank { grid-template-columns: repeat(2, 1fr); }.action-grid { grid-template-columns: 1fr; }.motion-side { grid-template-columns: 1fr repeat(3, auto); }.motion-side__identity { grid-column: 1 / -1; }.channel-selector { grid-template-columns: repeat(8, 1fr); }.pwm-mixer__row { grid-template-columns: 1fr; }.pwm-mixer__actions { justify-content: flex-end; }.system-output-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .output-matrix, .relay-bank { grid-template-columns: repeat(2, 1fr); }.action-grid { grid-template-columns: 1fr; }.motion-side { grid-template-columns: 1fr; }.motion-side__identity { grid-column: 1 / -1; }.channel-selector { grid-template-columns: repeat(8, 1fr); }.pwm-mixer__row { grid-template-columns: 1fr; }.pwm-mixer__actions { justify-content: flex-end; }.system-output-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); } .workbench-grid { grid-template-columns: 1fr; gap: 10px; }.workbench-grid > .card-layout-frame, .workbench-grid > [data-layout-card-id="terminal"] { grid-column: 1; }.workbench-grid > [data-layout-card-id="terminal"] { min-height: 390px; }.terminal-transcript { min-height: 190px; }.terminal-event-strip { grid-template-columns: repeat(5, minmax(145px, 1fr)); }.compact-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }.compact-fields .text-field:first-child { grid-column: 1 / -1; } .device-stage { min-height: 0; }.device-overview { grid-template-columns: 68px minmax(0, 1fr); }.device-overview__mark { width: 68px; height: 68px; border-radius: 18px; }.device-facts { grid-template-columns: 1fr; }.device-actions { grid-template-columns: 1fr; }.message-compose { grid-template-columns: 1fr; }.beep-row { flex-wrap: wrap; }.beep-row > div { flex-basis: 100%; } .subnav { width: 100%; overflow-x: auto; }.subnav button { flex: 1; white-space: nowrap; }.records-card .card__header { flex-direction: column; }.table-footer { flex-wrap: wrap; }.table-footer span:last-child { display: none; } diff --git a/Tools/Controller/web/src/types.ts b/Tools/Controller/web/src/types.ts index f2b67647b..489a29b86 100644 --- a/Tools/Controller/web/src/types.ts +++ b/Tools/Controller/web/src/types.ts @@ -206,6 +206,10 @@ export interface MotionSnapshot { } export interface Snapshot { + connection_generation?: number + pwm_values?: PWMValues + pwm_revision?: number + pwm_updated?: string host_instance_id?: string connected: boolean paused: boolean @@ -252,6 +256,10 @@ export interface Snapshot { } export interface StatusUpdate { + host_instance_id?: string + connection_generation?: number + board_connected?: boolean + have_status?: boolean time: string status: ControllerStatus error?: string @@ -512,11 +520,12 @@ export interface HostUISettings { export interface PeripheralDescriptor { key: string - kind: 'relay' | 'motion' | 'pwm' | 'display' | 'sensor' + kind: 'relay' | 'motion' | 'seat' | 'pwm' | 'display' | 'sensor' role: string index: number default_name: string - control: 'relay' | 'motion' | 'pwm-user' | 'role-specific' | 'read-only' + name?: string + control: 'relay' | 'motion' | 'seat' | 'seat-internal' | 'pwm-user' | 'role-specific' | 'read-only' icon?: string output_type?: PWMOutputType curve?: PWMCurve @@ -525,10 +534,13 @@ export interface PeripheralDescriptor { export interface ControlDescriptor { key: string - kind: 'relay' | 'side' | 'mosfet' + kind: 'relay' | 'side' | 'seat' | 'mosfet' order: number name: string - control: 'relay' | 'motion' | 'pwm-user' + control: 'relay' | 'motion' | 'seat' | 'pwm-user' | 'role-specific' + hidden?: boolean + locked?: boolean + group?: string icon?: string output_type?: PWMOutputType curve?: PWMCurve @@ -542,6 +554,10 @@ export interface PeripheralSettings { } export interface PWMValues { + host_instance_id?: string + connection_generation?: number + revision?: number + time?: string available: boolean selected_channel: number values: number[] 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/Tools/Controller/web/src/views.tsx b/Tools/Controller/web/src/views.tsx index 9a5f465bc..3fd41ca98 100644 --- a/Tools/Controller/web/src/views.tsx +++ b/Tools/Controller/web/src/views.tsx @@ -171,6 +171,7 @@ export interface SharedViewProps { relayedTerminal: Array broadcastTerminal: (entry: TabTerminalEntry) => void boardSettingsReadState: BoardSettingsReadState + focusControl?: { key: string; revision: number } } export interface AppInstanceSummary { @@ -622,6 +623,10 @@ export function ControlsView(props: SharedViewProps) { const [statusProfile, setStatusProfile] = useState('ready') const configurationEventID = props.events.find((event) => event.kind === 'config')?.id ?? 0 const pwmReportedRef = useRef(pwmReported) + const pwmRevisionRef = useRef(0) + const pwmOwnerRef = useRef('') + const currentPWMOwner = `${snapshot.host_instance_id}/${snapshot.connection_generation}` + if (pwmOwnerRef.current !== currentPWMOwner) { pwmOwnerRef.current = currentPWMOwner; pwmRevisionRef.current = 0 } const pwmAllBusyRef = useRef(pwmAllBusy) const pwmOperationsRef = useRef(null) const pwmSchedulerRef = useRef(null) @@ -643,10 +648,15 @@ export function ControlsView(props: SharedViewProps) { const pwmConfig = (channel: number) => pwmConfigRef.current[`pwm.${channel}`] const applyAuthoritativePWM = (response: PWMValues, acknowledgedChannel: number | null = null) => { + if (response.revision !== undefined && response.revision < pwmRevisionRef.current) return + if (response.host_instance_id !== undefined && `${response.host_instance_id}/${response.connection_generation}` !== pwmOwnerRef.current) return const authoritative = normalizePWMValues(response) + if (!authoritative.available) { setPWMError('PWM unavailable'); return } + if (response.revision !== undefined) pwmRevisionRef.current = response.revision pwmReportedRef.current = authoritative.values setPWMReported(authoritative.values) setPWMDraft((current) => current.map((value, channel) => { + if (pwmSchedulerRef.current?.isEditing(channel)) return value if (channel === acknowledgedChannel) return authoritative.values[channel] if (pwmSchedulerRef.current?.pendingChannels().includes(channel)) return value return authoritative.values[channel] @@ -658,16 +668,16 @@ export function ControlsView(props: SharedViewProps) { if (!pwmSchedulerRef.current) { pwmSchedulerRef.current = new PWMMutationScheduler({ operations: pwmOperationsRef.current, - commit: (channel, value) => rpc('controller.pwm.set', { channel, percent: pwmPercent(value, pwmConfig(channel)) }), + commit: (channel, value) => rpc('controller.pwm.set', { channel, raw_value: value }), onDraft: (channel, value) => { setPWMError('') setPWMDraft((current) => current.map((candidate, index) => index === channel ? value : candidate)) }, onAuthoritative: applyAuthoritativePWM, onPending: (channels) => setPWMPending([...channels]), - onError: (channel, cause) => { + onError: (channel, cause, revertDraft) => { setPWMError(cause.message) - setPWMDraft((current) => current.map((value, index) => index === channel ? pwmReportedRef.current[channel] : value)) + if (revertDraft) setPWMDraft((current) => current.map((value, index) => index === channel ? pwmReportedRef.current[channel] : value)) }, }) } @@ -684,7 +694,7 @@ export function ControlsView(props: SharedViewProps) { .then((value) => { if (active) setHostUI(value) }) .catch(() => { if (active) setHostUI(null) }) return () => { active = false } - }, [boardReady, configurationEventID]) + }, [boardReady, configurationEventID, props.events.find(event => event.kind === 'peripherals.changed')?.id, snapshot.port.name]) useEffect(() => { if (!boardReady || !available.pwm) { @@ -693,6 +703,7 @@ export function ControlsView(props: SharedViewProps) { } setPWMError('') const reconciler = new PWMReconciler({ + eventDriven: props.transport.streamState === 'open', operations: pwmOperationsRef.current!, canRead: () => !pwmAllBusyRef.current && (pwmSchedulerRef.current?.pendingChannels().length ?? 0) === 0, read: (signal) => rpc('controller.pwm.values', {}, signal), @@ -701,18 +712,23 @@ export function ControlsView(props: SharedViewProps) { }) reconciler.start() return () => reconciler.stop() - }, [available.pwm, boardReady]) + }, [available.pwm, boardReady, props.transport.streamState]) + // Selected-channel telemetry can predate the complete mutation ACK. It must + // not replace the mixer's full authoritative readback with an older sample. useEffect(() => { - if (!boardReady || !available.pwm || snapshot.status.pwm_channel < 0 || snapshot.status.pwm_channel > 15) return - const channel = snapshot.status.pwm_channel - const value = snapshot.status.pwm_value - pwmReportedRef.current = pwmReportedRef.current.map((candidate, index) => index === channel ? value : candidate) - setPWMReported(pwmReportedRef.current) - if (!pwmSchedulerRef.current?.pendingChannels().includes(channel)) { - setPWMDraft((current) => current.map((candidate, index) => index === channel ? value : candidate)) - } - }, [available.pwm, boardReady, snapshot.status.pwm_channel, snapshot.status.pwm_value]) + if (boardReady && snapshot.pwm_values) applyAuthoritativePWM(snapshot.pwm_values) + }, [boardReady, snapshot.pwm_revision, snapshot.pwm_values, pwmPending.length]) + + // Selected-channel telemetry can predate the complete mutation ACK. It must + // not replace the mixer's full authoritative readback with an older sample. + useEffect(() => { + if (!props.focusControl || !boardReady || !hostUI) return + const element = [...document.querySelectorAll('[data-control-key]')] + .find(element => element.dataset.controlKey === props.focusControl?.key) + element?.scrollIntoView({ block: 'center', behavior: props.reduceMotion ? 'auto' : 'smooth' }) + element?.focus({ preventScroll: true }) + }, [props.focusControl, boardReady, hostUI]) const pwmDefaults = [ 'MOSFET 1', 'MOSFET 2', 'MOSFET 3', 'MOSFET 4', @@ -806,7 +822,7 @@ export function ControlsView(props: SharedViewProps) { {Array.from({ length: 8 }, (_, index) => { const active = Boolean(snapshot.status.active_relays & (1 << index)) return ( -
+
R{index + 1} void command(`relay ${index + 1} ${active ? 'off' : 'on'}`)} />{peripheralName(`relay.${index + 1}`, relayDefaults[index])}
@@ -824,14 +840,16 @@ export function ControlsView(props: SharedViewProps) { : presented === 'stop' ? copy('STOPPED', 'متوقف') : copy('UNKNOWN', 'نامشخص') - return
+ return
- {side === 'left' ? peripheralName('motion.a', copy('Side A motion', 'حرکت سمت A')) : peripheralName('motion.b', copy('Side B motion', 'حرکت سمت B'))} + {side === 'left' ? peripheralName('seat.a', copy('Side A motion', 'حرکت سمت A')) : peripheralName('seat.b', copy('Side B motion', 'حرکت سمت B'))} {state?.transitioning ? `${label} · ${copy('SYNCING', 'همگام‌سازی')}` : label}
+
command(`relay side ${side} up`)} onHoldStop={() => command(`relay side ${side} stop`)}>{copy('Hold Up', 'بالا نگه‌دار')} command(`relay side ${side} down`)} onHoldStop={() => command(`relay side ${side} stop`)}>{copy('Hold Down', 'پایین نگه‌دار')} +
})}
@@ -846,8 +864,8 @@ export function ControlsView(props: SharedViewProps) { const pending = pwmPending.includes(channel) const name = pwmName(channel) const config = pwmConfig(channel) - return
-
{config?.icon === 'lightbulb' ? '💡 ' : config?.output_type === 'motor' ? '⚙️ ' : ''}{name}{pending + return
+
{config?.icon === 'lightbulb' ? '💡 ' : config?.output_type === 'motor' ? '⚙️ ' : ''}{name}{name === pwmDefaults[channel] ? `pwm.${channel}` : `${pwmDefaults[channel]} · pwm.${channel}`}{pending ? copy(`Board ${reported} · draft ${draft}`, `برد ${reported} · پیش‌نویس ${draft}`) : copy(`${pwmPercent(reported, config)}% brightness · raw ${reported}`, `${pwmPercent(reported, config)}٪ روشنایی · خام ${reported}`)}
@@ -859,14 +877,14 @@ export function ControlsView(props: SharedViewProps) {
- setPWMPercent(channel, percent)} /> + control.key === `pwm.${channel}`)?.locked} onEditStart={() => pwmSchedulerRef.current?.beginEdit(channel)} onEditEnd={() => pwmSchedulerRef.current?.endEdit(channel)} onChange={(percent) => setPWMPercent(channel, percent)} />
{config?.curve === 'gamma' && void updatePWMConfig(channel, { gamma })} />}
})}
- {[11, 12, 13, 14, 15].map((channel) => {pwmName(channel)}{pwmReported[channel]} / 4095)} + {[11, 12, 13, 14, 15].map((channel) => {pwmName(channel)}{pwmReported[channel]} / 4095)}
{pwmError || (pwmPending.length ? copy(`${pwmPending.length} channel${pwmPending.length === 1 ? '' : 's'} waiting for controller acknowledgement.`, `${pwmPending.length} کانال در انتظار تأیید کنترلر است.`) diff --git a/Tools/Dependencies/resolved-tools-lock.json b/Tools/Dependencies/resolved-tools-lock.json index 93734a62d..ca8161cc6 100644 --- a/Tools/Dependencies/resolved-tools-lock.json +++ b/Tools/Dependencies/resolved-tools-lock.json @@ -132,7 +132,7 @@ "installer_sha256": "c1f52294597c0b73786b2a78eb5d176d89226d2f21875eab75e783a8b1cefcc4" }, "web": { - "package_lock_sha256": "bb156c62f805b5813cae074f7b1e04e2ad5d3aadcc4996fd8f21632823928fc5" + "package_lock_sha256": "0d97267c5ef5797fd91a74e444ccff932747451c33081578583ff47e9cc79783" }, "build": { "package_lock_sha256": "323ac44b9417ec4a68487f026e41d4bc29199b7f40ded040de0d5b592318ca98" diff --git a/Tools/VirtualBoard/CMakeLists.txt b/Tools/VirtualBoard/CMakeLists.txt index 1802077da..efd8f8508 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,10 @@ 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) + 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/README.md b/Tools/VirtualBoard/README.md index 653eb6e72..e5db285b9 100644 --- a/Tools/VirtualBoard/README.md +++ b/Tools/VirtualBoard/README.md @@ -88,10 +88,74 @@ 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 +files under `/sys`. Custom adapters must write scalar files inside the owned +sensor directory: `%LOCALAPPDATA%/Programs/PCController/sensors` on Windows, +`~/Library/Application Support/PCController/sensors` on macOS, or +`$XDG_STATE_HOME/pccontroller/sensors` (default `~/.local/state/pccontroller/sensors`) +on Linux. Relative paths, traversal, prefix collisions and links escaping those +roots are rejected. Missing files within a permitted root still report unknown +and can recover when the adapter creates them. Arbitrary host-file reads are not +a sensor mapping feature. Map known 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..49f604087 --- /dev/null +++ b/Tools/VirtualBoard/dev-host.mjs @@ -0,0 +1,136 @@ +// 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, 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' +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' : '' +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') +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) +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, ownerHandle, createdOwner = false +try { + // Existing metadata is opened read-only without following a final symlink. + // A missing marker is created exclusively; if another launcher wins that + // race, fail closed instead of reopening a pathname selected by the winner. + ownerHandle = await open(marker, readFlags) +} catch (error) { + if (error.code !== 'ENOENT') throw error + ownerHandle = await open(marker, 'wx+', 0o600) + createdOwner = true +} +let config +let handle +let created = false +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, 'wx+', 0o600); created = true } + if (created) { await handle.writeFile(seed); config = JSON.parse(seed) } + else config = await readJson(handle) + 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') +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..4664228d9 --- /dev/null +++ b/Tools/VirtualBoard/src/sensor_sources.cpp @@ -0,0 +1,108 @@ +#include "virtual_board/sensor_sources.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace NativeHal { +namespace { +SensorSources sources; +uint64_t lastReadUs = 0; +bool readOnce = false; + +std::filesystem::path ownedSensorDirectory() { + auto env = [](const char *key) { const char *value = std::getenv(key); return value ? std::string(value) : std::string(); }; +#ifdef _WIN32 + return std::filesystem::path(env("LOCALAPPDATA")) / "Programs" / "PCController" / "sensors"; +#elif defined(__APPLE__) + return std::filesystem::path(env("HOME")) / "Library" / "Application Support" / "PCController" / "sensors"; +#else + const auto state = env("XDG_STATE_HOME"); + return (state.empty() ? std::filesystem::path(env("HOME")) / ".local" / "state" : std::filesystem::path(state)) / "pccontroller" / "sensors"; +#endif +} + +// A mapped scalar is not a general-purpose filesystem read. Resolve links +// before checking the complete directory boundary (not a textual prefix). +std::filesystem::path validatedSensorPath(const std::string &input) { + if (input.empty() || input.size() > 4096 || input.find('\0') != std::string::npos || input.find("..") != std::string::npos) return {}; + const std::filesystem::path requested(input); + if (!requested.is_absolute()) return {}; + std::error_code error; + const auto candidate = std::filesystem::weakly_canonical(requested, error); + if (error) return {}; + std::vector roots{ownedSensorDirectory()}; +#ifdef __linux__ + roots.emplace_back("/sys"); +#endif + for (const auto &root : roots) { + if (!root.is_absolute()) continue; + const auto canonicalRoot = std::filesystem::weakly_canonical(root, error); + if (error) { error.clear(); continue; } + const auto relative = candidate.lexically_relative(canonicalRoot); + if (relative.empty() || relative.is_absolute()) continue; + bool safe = true; + for (const auto &part : relative) if (part == "..") { safe = false; break; } + if (safe && relative != ".") return candidate; + } + return {}; +} +} +bool readSensorInteger(const std::string &path, int64_t &value) { + const auto validated = validatedSensorPath(path); + if (validated.empty()) return false; + std::error_code error; + if (!std::filesystem::is_regular_file(validated, error) || error) return false; + std::ifstream input(validated, 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"); + for (const auto &path : {value.temperature[0], value.temperature[1], value.voltage, value.current}) + if (!path.empty() && validatedSensorPath(path).empty()) + throw std::invalid_argument("native sensor paths must be absolute scalar files inside /sys (Linux) or the PCController owned sensors directory; traversal and escaping links are rejected"); + 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/dev_host.test.mjs b/Tools/VirtualBoard/tests/dev_host.test.mjs new file mode 100644 index 000000000..07b348c2d --- /dev/null +++ b/Tools/VirtualBoard/tests/dev_host.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, writeFile, readFile, rm, symlink } 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) + 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 () => { + 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}) } +}) + +test('invalid existing ownership cannot authorize a configuration seed', async () => { + const state = await mkdtemp(join(tmpdir(),'pccontroller-dev-invalid-owner-')) + try { + const marker = JSON.stringify({owner:'another-application'}) + await writeFile(join(state,'virtual-development.json'),marker) + const result = await run(state) + assert(result.error); assert.match(result.stderr,/wrong development directory owner/) + assert.equal(await readFile(join(state,'virtual-development.json'),'utf8'),marker) + await assert.rejects(readFile(join(state,'config.json')), {code:'ENOENT'}) + } finally { await rm(state,{recursive:true,force:true}) } +}) + +test('existing symlink ownership is refused without changing its target', + {skip:process.platform === 'win32'}, async () => { + const state = await mkdtemp(join(tmpdir(),'pccontroller-dev-symlink-')) + try { + const target = join(state,'protected.json') + const content = JSON.stringify({owner:'pccontroller-virtual-development'}) + await writeFile(target,content) + await symlink(target,join(state,'virtual-development.json')) + const result = await run(state) + assert(result.error); assert.match(result.stderr,/ELOOP/) + assert.equal(await readFile(target,'utf8'),content) + await assert.rejects(readFile(join(state,'config.json')), {code:'ENOENT'}) + } finally { await rm(state,{recursive:true,force:true}) } + }) diff --git a/Tools/VirtualBoard/tests/eeprom_menu_labels_test.cpp b/Tools/VirtualBoard/tests/eeprom_menu_labels_test.cpp index 1749687c9..f1bc797fd 100644 --- a/Tools/VirtualBoard/tests/eeprom_menu_labels_test.cpp +++ b/Tools/VirtualBoard/tests/eeprom_menu_labels_test.cpp @@ -203,6 +203,14 @@ int main() { testVersionedRecordAndTornWriteStayUnavailable(); testCrcRejectsPrintableXorCollisions(); testFactoryBlockReadsEveryPackedCell(); + for (unsigned page = 0; page <= 255; ++page) { + char output[EepromMenuLabels::LabelWidth]; + EepromMenuLabels::copy(static_cast(page), output); + for (uint8_t cell = 0; cell < EepromMenuLabels::LabelWidth; ++cell) { + require(output[cell] == EepromMenuLabels::read(static_cast(page), cell), + "copy and public cell reader disagree on page bounds/sanitization"); + } + } std::cout << "eeprom_menu_labels_tests: all checks passed\n"; return 0; } catch (const std::exception &error) { diff --git a/Tools/VirtualBoard/tests/firmware_controls_test.cpp b/Tools/VirtualBoard/tests/firmware_controls_test.cpp index c31b1c02a..3d3790cbd 100644 --- a/Tools/VirtualBoard/tests/firmware_controls_test.cpp +++ b/Tools/VirtualBoard/tests/firmware_controls_test.cpp @@ -538,6 +538,48 @@ void testTemperatureEEPROMIdentityAndThreshold() { "damaged temperature record was treated as persistent authority"); } +void testDisplayNumericBoundaries() { + SevenSegments segments; + segments.begin(5); + segments.showInteger(9999); + require(std::all_of(segments.rawSegments(), segments.rawSegments() + 4, + [](uint8_t cell) { return cell == 0x6F; }), + "largest positive four-cell number did not render"); + segments.showFixed(-999, 2); + require(segments.rawSegments()[0] == 0x40 && + segments.rawSegments()[1] == 0xEF && + segments.rawSegments()[2] == 0x6F && + segments.rawSegments()[3] == 0x6F, + "negative fixed-point boundary lost sign or decimal position"); + for (int32_t value : {int32_t(10000), int32_t(-1000), INT32_MAX, INT32_MIN}) { + segments.showInteger(value); + require(std::all_of(segments.rawSegments(), segments.rawSegments() + 4, + [](uint8_t cell) { return cell == 0x40; }), + "out-of-range number was narrowed to a misleading display value"); + } + segments.showInteger(0); + require(segments.rawSegments()[3] == 0x3F, + "zero did not recover from unavailable presentation"); +} + +void testEveryEasedBytePair() { + for (unsigned current = 0; current <= 255; ++current) { + require(TransitionMath::expandByte12(static_cast(current)) == + current * 16U + current / 16U, + "8-to-12-bit PWM expansion changed a calibrated value"); + for (unsigned target = 0; target <= 255; ++target) { + const unsigned distance = current > target ? current - target : target - current; + const unsigned step = std::max(1U, std::min(8U, distance / 16U)); + const unsigned expected = current == target ? current : + (current < target ? std::min(target, current + step) : + std::max(target, current - step)); + require(TransitionMath::easedByte(static_cast(current), + static_cast(target)) == expected, + "byte damping changed at a target or electrical boundary"); + } + } +} + void testBuzzerTimerAndQueue() { TonePlayer player(PIN_PB1); player.begin(); @@ -586,6 +628,8 @@ int main() { testDallasAbsentPullupBound(); testTemperatureEEPROMIdentityAndThreshold(); testBuzzerTimerAndQueue(); + testDisplayNumericBoundaries(); + testEveryEasedBytePair(); std::cout << "firmware_controls_tests: all checks passed\n"; return 0; } catch (const std::exception &error) { diff --git a/Tools/VirtualBoard/tests/transport_integration.test.mjs b/Tools/VirtualBoard/tests/transport_integration.test.mjs index 421696db4..08d3a674e 100644 --- a/Tools/VirtualBoard/tests/transport_integration.test.mjs +++ b/Tools/VirtualBoard/tests/transport_integration.test.mjs @@ -1,7 +1,7 @@ -import { spawn, execFileSync } from 'node:child_process' +import { spawn, spawnSync, execFileSync } from 'node:child_process' import { createConnection, createServer } from 'node:net' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { mkdir, mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir, homedir } from 'node:os' import { join } from 'node:path' import assert from 'node:assert/strict' @@ -47,6 +47,11 @@ async function connect(port) { const binary = process.argv[2] assert(binary, 'native binary required') const state = await mkdtemp(join(tmpdir(), 'pccontroller-native-conformance-')) +const sensorRoot = process.platform === 'win32' ? join(process.env.LOCALAPPDATA, 'Programs', 'PCController', 'sensors') + : process.platform === 'darwin' ? join(homedir(), 'Library', 'Application Support', 'PCController', 'sensors') + : join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'pccontroller', 'sensors') +await mkdir(sensorRoot, {recursive:true}) +const sensorState = await mkdtemp(join(sensorRoot, 'conformance-')) const children = [], sockets = [] const pidFile = join(state, 'board.pid') function start(args, argv0 = binary) { @@ -67,8 +72,78 @@ try { const status = await stream(f => f.opcode === 144 && f.sequence === 42) assert.equal(status.payload.length, 48); assert.equal(status.payload.readInt32LE(4), -2147483648) assert.equal(status.payload.readInt16LE(20), -32768) + // A read-only initial observation and quiet final PWM changes must push + // without any PwmValues request. These are RAM-only native firmware fixtures. + const initialPwm = await stream(f => f.opcode === 0x92 && f.sequence === 0) + assert.equal(initialPwm.payload.length, 34) + if (initialPwm.payload[0]) { + stdio.stdin.write(encode(0x11, 46, [0, 123, 0])) + await stream(f => f.opcode === 0x92 && f.sequence === 0 && f.payload.readUInt16LE(2) === 123) + stdio.stdin.write(encode(0x11, 47, [0, 234, 0])) + await stream(f => f.opcode === 0x92 && f.sequence === 0 && f.payload.readUInt16LE(2) === 234) + } 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. + for (const forbidden of [join(state, 'outside'), join(sensorRoot+'-other','value'), join(sensorRoot,'..','private-value')]) { + const rejected = spawnSync(binary, ['--stdio','--memory','--sensor-temp0',forbidden], {timeout:2000}) + assert.notEqual(rejected.status, 0, 'uncontrolled sensor path must be rejected') + assert.match(String(rejected.stderr), /native sensor paths/) + } + const tempPath = join(sensorState, 'host-temp'), voltagePath = join(sensorState, 'host-voltage'), currentPath = join(sensorState, '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') + + // 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')) @@ -107,4 +182,6 @@ try { } catch {} await new Promise(resolve => setTimeout(resolve, 200)) await rm(state, { recursive: true, force: true }) + assert(sensorState.startsWith(sensorRoot + (process.platform === 'win32' ? '\\' : '/'))) + await rm(sensorState, { recursive: true, force: true }) } diff --git a/docs/BINARY-UDP.md b/docs/BINARY-UDP.md new file mode 100644 index 000000000..1570decb6 --- /dev/null +++ b/docs/BINARY-UDP.md @@ -0,0 +1,154 @@ +# 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 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. + +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. diff --git a/docs/WEB-HOST-TERMINAL.md b/docs/WEB-HOST-TERMINAL.md new file mode 100644 index 000000000..b0e039701 --- /dev/null +++ b/docs/WEB-HOST-TERMINAL.md @@ -0,0 +1,94 @@ +# Web host terminal presentation + +`controller web` now presents the full existing TUI in an interactive terminal, +including when `--no-open --no-tray` suppresses browser/tray surfaces. Those flags +are not a terminal-disable policy. The TUI attaches to the already-owned primary +over the current IPC/event contract: it cannot acquire another UART or create +another board coordinator. + +`--no-tui` explicitly disables it. Noninteractive redirected streams and embedded +service hosts also cannot run an interactive TUI. These paths hide only the +process's sole-owned classic Windows console using existing pure-Go +`GetConsoleWindow` / `GetConsoleProcessList` / `ShowWindow(SW_HIDE)` calls. No +cmdow, shell helper, arbitrary window hiding or shared terminal hiding is used. +The startup splash appears immediately for terminal Web hosts; one bounded +hide recheck remains for an updater still sharing its console during health +commit. Exiting/failing the TUI ends the foreground host rather than leaving a +blank visible host window. Service/redirected hosts remain background services. + +New startup-mode fixtures cover explicit no-TUI, false overrides and preservation +of terminal UI with browser/tray disabled. Go vet passes; tests/CI were skipped +at the user's request. Packaging and live host identities are separate delivery +evidence; no board actions or programming were used for presentation checks. + +The stage-only Windows package completed in 44.6 seconds from source topic +`6fb26090` with source digest +`b8f39b956baa62f53a489569f3d3977192ef4ea56ea5ca3f40c4b5f5f11e7242`. +Candidate executable SHA-256: +`d4204a0754740a12285d993f51448202aa74807c620909e8c6ac69a198e10e8c`. +Web typecheck/build/integrity, Win32 resources and existing programmer adapter +checks passed. This pass skipped tests, package vet (narrow vet had already +passed separately), UPX and C-ABI library rebuilding; it did not remove existing +C-ABI implementations or flash the embedded firmware/default EEPROM images. + +The generated Web build was preserved in a scoped named Git stash +`generated-web-output-web-host-terminal-package`; package output remains in the +product-owned `.build/package` directory. It is not authored source for a new +generated-only PR. No running controller was restarted/replaced for this pass. +Thus the TUI's production visibility is not yet live acceptance. + +[PR #662](https://github.com/atomicdeploy/PCController/pull/662) is stacked on +the frozen mobile UI #661 and retains WS #660. Do not reset or overwrite these +prerequisites to flatten the source history. Runtime activation must use the +verified host's ordinary updater and preserve its coordinator/mirroring state. + +## Required checks and Cafe activation follow-up + +The user subsequently authorized required merge checks. The integrated topic +retains the open controller/mobile and dependency branches rather than replacing +their implementations. Required checks exposed a duplicated PWM deadline helper, +obsolete unconfigured-board/TUI assertions and a missing environment bootstrap +in the isolated UDP example; these have focused repairs. The mobile owner also +reproduced and repaired the narrow RTL connection-header overflow and updated +two obsolete assertions, with 26 focused checks and a clean analyzer. + +Cafe's primary-owned host updater acknowledged candidate source `352e6371` +after restarting in the existing interactive session. Its executable SHA-256 is +`8c22bdc95caa50f8d584327b46da97c44a23184fafc09df0958946c2340c2636`. +Pealayer's integrated `5b3d5c1` package was separately accepted by its own updater +with Cafe's retained libmpv, and Cafe acquired publishing authority while paused. +Seven controller effects and a ready, error-free empty hardware plan were read +back. This is authoring readiness, not physical playback or CPU acceptance. + +Paused visible CPU remains high. Explicit `POST /api/diagnostics/cpu-profile` +provides one five-second application-only Go profile to loopback callers; +existing Origin/authentication checks still apply. It accepts no arbitrary file, +URL, duration or board action, stores nothing itself and exposes no public pprof +listener. Use the private response with `go tool pprof`; do not publish profiles +or enable OS profiling policies to work around their denial. Profiling is not +automatic and does not change telemetry or audio cadence. + +The full AVR check discovered overlap with the reserved firmware identity block. +Semantics-preserving wire-cursor and byte-damping/PWM-expansion improvements are +checked against the shared native firmware, including all 65,536 damping pairs +and all 256 calibrated byte expansions. The full default AVR image now builds +and passes the footprint/stack guards with 50 flash bytes and an estimated +231 SRAM bytes free (96 required). All 24 native firmware checks pass. No board +was flashed, identity moved into bootloader space or feature removed; the +fresh Linux/Windows required merge checks still need to confirm this tree. + +The optional EEPROM-menu-label profile also passes its complete footprint and +stack checks after sharing its label deadline arithmetic: 24 flash bytes and +an estimated 230 SRAM bytes remain free (96 required). The default profile and +all 24 native checks pass too; EEPROM CRC, label sanitization and the original +650/450 ms dwell values remain unchanged. Neither image was flashed. + +The explicit Cafe application profile attributed substantial allocation work +to repeated full snapshot/catalog construction in PWM projection. That path now +uses the same authoritative snapshot once and resolves only its sixteen PWM +names through the existing presentation resolver, instead of constructing the +unrelated control catalog and repeatedly cloning configuration. Calibration, +profile/legacy name precedence, host/generation/revision/time and immediate +final value delivery remain unchanged. Focused regressions compare every PWM +name with every full profile catalog and require one configuration read. Live +post-update process CPU remains a separate required acceptance check. diff --git a/docs/WS-LIVE-VALUES.md b/docs/WS-LIVE-VALUES.md new file mode 100644 index 000000000..45d37ef1b --- /dev/null +++ b/docs/WS-LIVE-VALUES.md @@ -0,0 +1,22 @@ +# Live value push contract (2026-10-13) + +The shared AVR/native firmware observes the complete 16-channel PWM readback at a bounded 50 ms cadence and sends changed values with the existing PwmValuesResponse opcode and unsolicited sequence zero. It compares against the last sent values so a quiet final change is retained. This adds no output write. AVR SRAM/physical flash acceptance remains open. + +Go caches complete PWM observations, calibration, monotonic revision, timestamp and connection generation. Serial observations, CLI writes and native readbacks use the same publisher. RGB throttling keeps one trailing observation per owner; WebSocket/Socket.IO presentation keeps one pending frame per continuous domain. Segments, buzzer and lifecycle edges stay immediate. New subscriptions receive the current cached snapshot, and PWM changes receive controller.pwm.values with all 16 calibrated channels. Low-rate authority changes carry a cached snapshot; macro steps do not amplify full snapshots. + +Known consumers audited/updated: + +| Consumer | Current treatment | +| --- | --- | +| Unified Android Flutter | Buffers pre-ACK notifications; validates selected host/generation/revision and complete calibrated PWM; fences older reads/pending writes. Healthy revised pushes cause no PWM HTTP read. Legacy/incomplete events and lost WS use bounded fallback. | +| PCController Web | Buffers pre-ACK notifications, merges complete PWM revisions, protects current edits, stops periodic PWM reads while WS is healthy. | +| Remote Go TUI | Buffers pre-ACK values, consumes snapshots/PWM, retains separate event domains when coalescing and fences generations. | +| Generic Go RPC SDK | Opaque notification transport accepts new methods; public snapshot decoding preserves unavailable settings without bypassing validation of available settings. | +| Native hostbridge, mirrors, UDP, Socket.IO | Shared Go runtime publication; WS and Socket.IO use the same event-stream worker. No independent client polling implementation added. | +| Pealayer native/Web | Separate coordinated change consumes complete calibrated PWM/snapshot facts, fences owners, invalidates hardware cache and releases Web drafts when actual newer observations arrive. | +| Pealayer typed relay | Request/response route remains bounded fallback; no invented controller subscription on player WS and no recursive proxy. | +| Legacy Pakar/ESP mobile transport | Different protocol/business domain, unchanged. | + +Validation: selected stable-path Go tests in five packages include two real loopback WS clients and reconnect baseline, per-domain quiet final values, RGB timer ownership and TUI coalescing. Offline SDK snapshot decoder regression passes. PC Web 30 focused tests and TypeScript pass. Flutter 16 focused push/event/PWM/recovery/front-panel scroll checks and analyzer pass. Pealayer complete-push ownership/calibration Rust test, Web draft checks and TypeScript pass. Shared native firmware objects compiled, UART unit and memory-only native stdio/TCP conformance passed. The running dev-host binary is owned elsewhere and was preserved; candidate linked to a project-owned build-programs path. + +These are source/loopback/RAM checks. Production Go/Pealayer replacement, AVR physical flash, hardware/audio/UI acceptance and the phone installation storage gate remain separate. Android candidate is 0.1.13+14; installed identity and device-specific delivery are recorded after the build. diff --git a/docs/verification/buzzer-mirror-jitter.md b/docs/verification/buzzer-mirror-jitter.md index 8b23d461c..4ff0d1d56 100644 --- a/docs/verification/buzzer-mirror-jitter.md +++ b/docs/verification/buzzer-mirror-jitter.md @@ -2,6 +2,65 @@ Owner: Linux/board mirroring issue #106. +## Follow-up: short-note and display fidelity + +The 50 ms receiver cushion alone did not cover arrival bursts beyond that +cushion: the old device-clock deadline deducted lateness from a fresh note, +and could discard a short note entirely. A fresh observation now reanchors +its source with the same 50 ms cushion if its mapped start has already passed, +including a fresh first note delayed by scheduler handoff. The original duration +is retained; following notes resume +device-clock cadence from that anchor. This is bounded playout recovery, not +sample-exact acoustic synchronization or permission to replay old audio. +Late timer wakeup or serialized backend cleanup also rebases fresh source +playout and its queued followers, retaining declared durations instead of +spending those delays out of a 1 ms note. Stale observations are not revived. + +Dispatch preserves Runtime's local monotonic receipt timestamp (also assigned +when remote events are ingressed), rather than restamping after integration +work. Observations queued longer than the existing 250 ms freshness bound +are not rebuffered. Explicit Stop still cancels/flushed its source immediately; +generation replacement still discards prior buffered notes. Mirror enablement, +board sound policy, native kernel finite-tone limits and cleanup are unchanged. + +The WebSocket state limiter previously shared one timestamp between RGB, PWM +and segment frames. An RGB frame could consume the budget for the next digit +or blink frame. Continuous domains now have independent budgets, while changed +segment edges bypass decimation. Pealayer requests immediate changed state +and uses its hardware revision for peer delivery rather than a 100 ms sample. +No extra board polling, synthetic frames or interpolation is introduced. + +Go host compilation and focused package vet passed. Pure regression cases were +added for fresh 10 ms recovery, scheduler handoff, stale queued notes and cross-domain frame +budgets; tests/CI were not run in this iteration at the user's request. +Pealayer passed offline cargo check with existing warnings. Deployment and +physical short-note/segment fidelity must be recorded separately below. + +### Linux receiver delivery, 13 October 2026 + +The final receive-only x86-64 Linux build was deployed on `asus@server` and +the existing user service restarted. Source commit `564791b2` is GitHub-backed; +embedded source SHA-256 is +`0ff3f476c592a6b89a283b3a082c17694276179a12cfc636ecef4f5d53f505da`. +Both installed file and running `/proc/2174666/exe` matched executable SHA-256 +`27d6a27041a2acf06ed8879e99418db111d7377042ed975ac26cc0f649dabc5d`. +Version is the explicitly development-labelled `0.0.0-dev.mirror-fidelity`. + +Service status was active/running, with Cafe connected, command forwarding +disabled, event forwarding disabled, host mirroring enabled and native backend +selected. Existing owner, mode, ACL and `cap_sys_tty_config=ep` were retained; +no service/config edit or board firmware write occurred. The original receiver +is recoverable at `bin/controller.before-mirror-fidelity` (hardlink, SHA-256 +`313830be2cb1ed41aafd7be38d6aa58652e0a0614e6014cebe6d3773b3468590`). +An intermediate receiver was superseded after further timer/handoff review; +only the final identity above is the current delivery claim. + +No physical tone, motion or relay test was triggered. Human short-note, +melody/pause, cancellation and display fidelity are not inferred from health, +compile/vet or matching hashes. Pealayer source `ec74666` passed offline cargo +check, but its native fix is not installed: production packaging requires a +clean checkout, and existing generated Web-distribution edits remain preserved. + ## Cause and behavior The receiver anchored the first device-clock note at local receipt time, then diff --git a/docs/verification/serial-response-deadlines.md b/docs/verification/serial-response-deadlines.md new file mode 100644 index 000000000..25ccbbb22 --- /dev/null +++ b/docs/verification/serial-response-deadlines.md @@ -0,0 +1,65 @@ +# Serial admission and PWM response budgets + +The reported GET_STATUS (0x02) expired at 1500.8 ms behind an ADDRESSABLE_LED +(0x16) request registered for 1550.4 ms. The strip request formerly allowed a +two-second response wait. It now has a 750 ms response budget after admission; +queued strip packets yield to ordinary status/control requests, while prepared +timeline work keeps its highest priority and shorter existing deadline. Nothing +interrupts or retries an in-flight mutation. Three consecutive missing strip +frame ACKs terminate the stream with unconfirmed-output evidence instead of +occupying the UART indefinitely. Isolated transient drops still render from the +current animation time, never replay an old frame. + +PWM RPC formerly inherited the 15-second server budget, while Pealayer waits +three seconds on its ordinary control socket. Mutation and all-channel readback +now share one two-second context across TCP/WebSocket/UDP JSON-RPC and HTTP. +Read-only PWM queries are also bounded. No mutation retry is added. A mutation +failure does not issue a following read. An ACK followed by failed readback is +reported as `confirmation_unavailable`, resource `pwm`, `write_acknowledged=true`, +`retryable=false`, not a fake write rejection or invented board values. HTTP and +RPC share the lock/safety gates and deadlines. Pealayer keeps a healthy socket +for this typed server rejection, instead of pausing on a false TCP timeout. + +The paired Pealayer change restores its short socket timeout on every preparation +failure path and gives late/unrelated replies one absolute request budget. +Transport failures still pause/discard the failed socket and never replay the +original mutation. Existing 50 ms execution and 250 ms freshness guards remain +unchanged; no firmware, remote-access service or authorization change is needed. + +Added fake-port/pure regressions for strip timeout/priority, repeated frame failure, +shared PWM deadlines and acknowledged-write/failed-readback distinctions. Suites +and CI are skipped under the user's instruction. One staged host build completed +in 30.3 seconds with selected native/Web/package checks. Package +`.build/package/host-136280` has host source digest +`d4e7a7b87601ef5328498542527dc03e90c8048308eb3f561e85c4e97fe4fe92` +and executable SHA-256 +`37e4ccbf511bc30cac0dcbafaa38a4a55ffad5d02e187a499fddcb9e58109342`. +This package preserves the already deployed development lineage; it is not a +package of the isolated main-based PR tree. Actual physical failure injection +and PWM changes are not authorized merely to reproduce these errors. + +## Cafe delivery, 13 October 2026 + +Primary-owned update `op-091610e2d50112ba` completed with +`terminal_verified=true`; the installed executable hash matches the package. +The controller configuration remained semantically identical to private backup +`verification/serial-response-deadlines/config.json` (SHA-256 +`82a8fd03af13add569555f741905a9d91cb650ce95d133e5e71215adcfbedba5`). +Three live GET_STATUS calls and three full PWM reads over the same 3-second +NDJSON TCP transport returned in 19.1/8.4/8.8/11.8/13.5/23.5 ms respectively, +without errors. The first diagnostic writer used a BOM and was rejected as +invalid input; the corrected UTF-8-without-BOM probe matches native wire output. +No physical output was changed. Pealayer remained paused and hardware/controller +connected, without hardware/sync error, at matching desired/prepared/clock ACK 88. + +Source checkpoint `db407fce` was isolated onto main as `969969ca` for +[PR #659](https://github.com/atomicdeploy/PCController/pull/659), preserving +concurrent mobile work without switching the shared checkout or creating a +worktree. Protected branch checks block merging; normal auto-merge is enabled, +with no policy override or CI run. Generated Web output is preserved in stash +`853e1a3616eb22203818ef60dd43f98d76df1e53`. + +The paired [Pealayer PR #176](https://github.com/ToghrolTP/pealayer/pull/176) +has a compiled/smoke-verified candidate. Its runtime replacement is held for a +current Cafe draft-state confirmation. Successful read-only probes do not prove +physical PWM gesture acceptance, injected ACK-loss recovery or a playback soak. diff --git a/docs/verification/startup-console.md b/docs/verification/startup-console.md new file mode 100644 index 000000000..489ceb1dc --- /dev/null +++ b/docs/verification/startup-console.md @@ -0,0 +1,71 @@ +# Windows startup console and TUI first paint + +Cafe's reported blank window was a Web host, not a running terminal UI: +`controller.exe web --no-open --no-tray`, interactive session 4. The background +primary survived with a visible classic console after the host updater restarted +a console-subsystem executable. No TUI renderer existed to fill that window. + +The first candidate's live diagnostics found that this explicit background host +also inherited a console stream. A redirected-stream-only guard therefore +preserved the blank window. The corrected rule permits hiding an explicitly +UI-less Web host's own window only when the current process is its sole owner. +Ordinary Web commands with interactive streams, shared terminals, TUI commands +and machine-readable invocations retain their presentation. No arbitrary-window +lookup, console detachment, process termination or board command is introduced. + +Windows updater helpers and replacements use CREATE_NO_WINDOW for background +work without combining conflicting CREATE_NEW_CONSOLE/DETACHED_PROCESS flags. +Interactive terminal I/O and the replacement's normal Ctrl+C behavior remain +preserved. A bounded, one-shot 12-second startup recheck handles an old helper +sharing the console until its 10-second health commit; no repeating poll is added. +The bridge diagnostic read reports current-process console state freshly. + +Actual TUI invocations print the product title and startup text immediately, +before environment/configuration and transport initialization. Remote TUI setup +adds a connecting line before the initial IPC handshake and settings reads. +Both are restricted to real terminal output; JSON/version/helper output is not +contaminated. No fabricated percentage or board readiness is displayed. This +does not claim the startup handshakes have become instantaneous. + +Pure regressions cover command classification, explicit background flags, +startup text/pipe omission and Windows replacement presentation. Tests and CI +are intentionally not run under the user's standing instruction. Staged builds +still perform TypeScript, fresh Web bundling, native compilation, C ABI smoke +and package integrity checks. The build now reuses npm's installed lock graph +only when declarations, lock metadata, installed versions and package-directory +timestamps match; invalid caches still use npm ci. This avoids destructive +dependency reinstall churn without reusing an old Web bundle. + +## Cafe delivery, 13 October 2026 + +The corrected staged package is `.build/package/host-107216`; host source digest +`bd686fdad96ee99a0463c7e19f737088d058cb4634a169eefc6c73f39bc9ce94`, +controller SHA-256 +`f28330f2ddd9336599be4178290a81c239582ba295ae64c8c37b2e2763ffa11a`. +The 30.4-second build passed the selected compilation/package checks with +`locked-cache-verified` dependencies and skipped suites. It preserves the +previously deployed development lineage; it is not a package of the isolated +main-based PR tree. + +Primary-owned update `op-2b1ae95fc68b3871` completed with +`terminal_verified=true`. A fresh bridge RPC identified PID 83576 in Cafe's +interactive session 4 and reported `console_visible=false`, +`console_attached=true`, `console_sole_owner=true`, +`console_interactive_streams=true`. The installed executable hash matches the +package. Thus the inherited console was hidden, not detached or forcibly killed. +The previous candidate `5c85b2479cff642787312233afc3efc0f18e06335248180289ed4c9cd96c21f2` +had correctly reported visible=true and was not falsely accepted as fixed. + +Controller configuration remained semantically unchanged against the private +named backup `verification/startup-console/config.json` (backup SHA-256 +`82a8fd03af13add569555f741905a9d91cb650ce95d133e5e71215adcfbedba5`). +Pealayer remained paused, connected to the controller and hardware, with no +hardware/sync error, no requested Play, and matching desired/prepared/clock ACK +revision 87. No physical output command, playback start, firmware flash or +unrelated console/process action was issued. + +The source checkpoint is `e05ebc9b`, isolated onto main as `7ebf4a59` without +switching the shared checkout or creating a worktree. Concurrent mobile edits +remain outside the fix. The actual TUI first paint has not been captured on Cafe: +the running surface is a background Web host. Console visibility was verified +through process-local diagnostics, not a claimed screenshot/human UI acceptance. diff --git a/docs/verification/web-channel-controls.md b/docs/verification/web-channel-controls.md new file mode 100644 index 000000000..cfa1b6b8f --- /dev/null +++ b/docs/verification/web-channel-controls.md @@ -0,0 +1,66 @@ +# Web channel search, PWM drafts and motion geometry + +The command palette previously searched page titles and static commands only. +It now reads the profile-resolved peripheral catalog on open and relevant catalog +events, searches current names/keys/groups, and reveals/focuses the matching +control. Search never actuates an output. Hidden controls are not offered. + +Browser UI configuration and PWM readback previously used global base names, +ignoring the active board profile's presentation. They now share that resolved +catalog. The mixer displays the operator's current name together with its physical +MOSFET/user-PWM identity and stable key, so a renamed output is still identifiable. + +The mixer combined full PWM reads/write ACKs with narrow selected-channel telemetry +that could predate a completed write. That older sample could replace the displayed +draft. Also, a failed older mutation rolled back the draft even when a newer desired +value was queued. Full serialized PWM reads/ACKs now own mixer confirmation; +pointer/keyboard/number editing protects drafts between ACKs, final release flushes +the latest intent, and older failures cannot roll back a newer draft. Exact raw +values are submitted once rather than converted back to percent and through the +curve again. No mutation retry, fabricated board confirmation, new polling loop +or timing/safety relaxation is introduced. + +At narrow widths the motion identity spanned a four-column grid, but the following +three buttons landed in `1fr auto auto`. Hold Up inherited all spare width. A +separate equal-three-column action group now keeps Up/Stop/Down the same size, +with a one-column parent on narrow screens. Interrupted hold gestures still stop. + +Added pure search, overlapping-failure/gesture and profile-name regressions. +Tests/CI are skipped under the user's standing instruction. TypeScript passed; +host compilation, delivery, rendered geometry and physical gesture acceptance +are recorded separately below rather than inferred from source. + +## Cafe delivery, 13 October 2026 + +The root checkpoint `fe02a1e1` is published on the shared owner branch; only the +13 relevant Web/Go/test/doc paths were committed, preserving ongoing mobile edits. +An isolated index reconciled the fix onto main for PR +[#657](https://github.com/atomicdeploy/PCController/pull/657), without switching +the shared checkout, creating a worktree or importing the pending VirtualBoard +feature. One UI-catalog conflict was resolved by retaining main and applying only +the profile-catalog fix. The PR remains policy-blocked with normal auto-merge +enabled. No administrator override, security dismissal or CI/test run was used. + +One staged host build completed in 54.2 seconds, with TypeScript/Web bundle, +host/C ABI compilation and package integrity checks. The package host source +digest is `91f289f52bf716f08c3e26310991e3cec8875aedc80934749170084075e6784a`; +controller SHA-256 is +`505162cd4f8d4f12b42e17cbfd3b913ae8b4bfad90d1f15e41697ed1a1724a2e`. +The package retains the already deployed development lineage; it is not claimed +as a build of the isolated main-based PR tree. Generated distribution output is +preserved separately; the mobile owner did not change the Web/Go files. + +Cafe's primary-owned updater completed `op-2901a89fe99e6f52` with +`terminal_verified=true`. A fresh read-only check verified the installed digest, +semantically unchanged controller configuration and profile-resolved `Window` +in both browser UI settings and complete PWM readback. Channel 0 was observed at +raw 463/logical 36.981 percent with the existing gamma 2.4; no output mutation was +issued. Pealayer remained paused, connected and without sync error, at matching +desired/prepared/clock ACK 85. Private rollback/configuration backup remains under +the host's program-root `verification/web-channel-controls`. + +A temporary browser-inspection tunnel invocation was denied by tool policy and +was not retried through an alternate path. No live rendered geometry or physical +gesture pass is claimed. A supervised Window-only slider probe was requested; +until answered, real PWM/seat/relay outputs remain unchanged. Source compilation +and live catalog verification are distinct from human/physical UI acceptance. diff --git a/mobile/ACCEPTANCE.md b/mobile/ACCEPTANCE.md index 7c822e886..f5ba5bbf5 100644 --- a/mobile/ACCEPTANCE.md +++ b/mobile/ACCEPTANCE.md @@ -1,5 +1,39 @@ # Unified native controller acceptance +## User acceptance requirements, 2026-10-13 + +**The deployed app is not accepted.** This list remains durable even where a +feature has source coverage or an earlier deployment receipt. Check items only +when the named behavior is demonstrated; human acceptance remains separate. + +- [ ] Professional unified visual design at least matching Pealayer egui and + both projects' Web/native UIs, in light/dark, phone/tablet and scaled text. +- [ ] Channel indicators at the trailing end of card headers, truthful + On/Off/Unknown state, three-dot management menus and polished activity tabs. +- [ ] Accessible settings, appearance/preferences, launcher icon selection + and selectable multiple branded launcher entries. +- [ ] Actual Android beep mirroring with bounded timing, cancellation, + reconnect ownership and background cutoff verified. +- [ ] One contextual front-panel button row, no duplicated menu/key rows. +- [ ] Complete live measurement charts and working live seven-segment frames; + partial LCD/menu availability must not hide independent segment authority. +- [ ] Board monitoring, telemetry, firmware import/upload/backup/progress, + front panel, seven-segment, keys, buzzer, mode and channel management. +- [ ] Branded Pealayer mini-app, playback/workspaces/media/effects/designer, + migrated desktop/Web controls and unified activities. +- [ ] Mutual PCController/Pealayer peer bridges with scoped admission and + real acknowledgement; no unrestricted recursive proxy. +- [ ] Local JSON-RPC/IPC, NDJSON, Android Intents and supported ADB, Termux, + root-shell, Tasker, WebSocket and TCP send/receive paths verified on Android. +- [ ] Remote and full native standalone control, with the actual supported + hardware/coordinator route stated (offline drafts alone are insufficient). +- [ ] Deploy, launch and test on every reachable distinct Android device, + with screenshots and interaction receipts; physical tablet tests if found. + +The active improvement goal must preserve unfinished gates and resolve +observed defects. Implementation, analyzer/tests, deployment, physical/audio +behavior and human visual acceptance are distinct evidence categories. + ## Implemented source scope The app is Android-native with responsive phone/tablet layouts, shared routing, @@ -7,6 +41,83 @@ PCController control/telemetry/front-panel/firmware activities, Pealayer remote activities, and offline drafts/sequence editing. Existing host protocols and physical safety admission are reused without adding an unrestricted proxy. +## Improvement pass, preview 0.1.2+3 + +- Channel states moved into trailing card headers beside three-dot management. + Presentation editing uses a native icon picker, color swatches, folder/name + fields, host profile revision guards and host-owned channel locking. +- Activity tabs use a shared selected surface and stable horizontal scrolling. + Front-panel navigation and HOST keys now share one contextual four-button + row; unknown panel mode cannot dispatch a key. Host mode is its own control. +- Settings contains System/Light/Dark, automatic/mobile/tablet layout, compact + controls, mirror volume and one/both branded launcher entries. Both aliases + target the same single-task app; launcher changes never kill the app. +- Current `buzzer.note` frames now reach the existing native frequency-driven + PCM receiver. Timing preserves bounded MCU deltas, shortening/dropping late + notes, duplicate rejection, zero-note Stop, peer/generation ownership, + background/disconnect cutoff and a finite native receiver lease. Mirroring + is explicitly enabled in Settings; physical audibility remains unverified. +- Local HTTP/WS JSON-RPC, TCP NDJSON and Intent dispatch now additionally cover + current-mode panel keys, bounded display messages/tones, buzzer Stop and host + mode. Target, opt-in actuation, E-STOP and mutation concurrency guards remain. +- Pealayer playback uses advertised media-session actions in one transport row, + stable seek/volume drag previews, the actual 0..130 volume domain, contextual + mute, actual workspace profiles, speed, chapters, audio and subtitles. +- All five bounded telemetry histories/charts and independent segment-frame + authority remain present. Renderer captures replay earlier measured values; + they are not evidence of a currently connected Android physical board. + +Verification: locked offline restore, analyzer with no issues, 117 full tests +passed (two opt-in tests skipped), followed by 13 focused final checks and the +opt-in renderer. Read-only snapshot/catalog/subscription smoke passed against +the isolated local shared-firmware development host, not physical hardware. + +Both distinct reachable Samsung devices (phone SM-S908E and tablet SM-X906B) +received the development-signed ARM64 APK in place and launched. Repeated +Controller launcher invocation reused its activity. Android Intent requests +with an invalid token reached the Dart router on both devices and returned +JSON-RPC `-32001`. Both screens remained locked: touch/visual/native launcher +selection, authorized local transports, audible mirroring, live seven-segment +and physical peer acceptance are still pending. Android refused shell-based +component toggling; no launcher selection is claimed from that rejected check. + +The reported Cafe peer endpoint was unreachable from this workstation during +this pass. The earlier blanket Cafe smoke-launch denial is superseded by +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. @@ -15,12 +126,40 @@ 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 dispatch, mutual live application acknowledgement and its Pealayer→board - route need a running approved host. Existing local GUI/Cafe smoke-launch - denials are not bypassed by this app delivery. + route need a reachable approved host. A separate host delivery receipt does + not establish Android connectivity or mutual live application acceptance. - No AVR flash, reset, motion, relay/PWM write or physical tone is used merely to verify app/UI styling. Host/programming admission, physical recovery, flash/readback and firmware persistence still need explicit live acceptance. @@ -29,12 +168,12 @@ physical safety admission are reused without adding an unrestricted proxy. the intended standalone hardware route before implementing that expansion. - Full Web/native parity is unfinished: graphical multi-track cue dragging, full RF/I2C/strip settings, discovery onboarding, palette/icon/folder editing, - exact Android buzzer mirroring, tablet physical-device acceptance and a + actual Android buzzer timing/audibility, tablet interaction acceptance and a 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. @@ -61,3 +200,414 @@ 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. + +That source checkpoint preceded the mobile bug-priority pass and the direct +saved-drafts confirmation recorded below. Design, unlocked UI, standalone shared +core/USB and physical mirroring/peer/firmware acceptance remain open. + +## Mobile bug priority: MOSFET setpoints and relay layout + +Relay power actions now use semantic green/red instead of the appearance accent, +including explicit ON/OFF rows, compact power icons and unknown-state menu choices. +Filled, tonal and outlined preferences remain supported; disabled actions resolve +to muted surface colors and all existing target/lock/E-STOP/busy guards remain. +Card captions, action labels, fact rows and state pills share outer-leading trim +for centered text beside fixed icon boxes without changing font size or wrapping. +The desktop companion reserves density/font-aware header height while retaining +matching caption/inline-editor slots. Dart analyzer passed; a pure style regression +was added but not executed in this root pass, per the user's tests/CI skip request. +These source changes do not supersede the last exact Android delivery receipt. + +PWM edits now keep the drag/pending thumb through older snapshots until the +typed setter returns its native post-ACK readback. Pre-write read revisions are +fenced, owner/profile changes cancel old results and an uncertain write is never +replayed. Unknown levels use an empty bounded input rather than a false zero. +Protected PWM automation preserves fractional percentages and its JSON receipt. + +Preferences save compact one-line relay cards or two-line explicit ON/OFF cards. +Unknown compact state offers explicit power choices; action callbacks pin the +rendered target and revalidate current catalog/lock/hidden state. Authored groups +are preserved; ungrouped MOSFET controls use the PWM group. Package-sharing +network changes refresh actual interfaces independently of auto-connect choices. + +Eight focused pure widget/transport/layout regressions passed, alongside the +controlled renderer, analyzer and one ARM64 release APK build. Cafe read-only +queries reported PWM available with valid channel values; no physical output +mutation was performed. Screenshots are recorded-value renderer evidence. + +David directly confirmed "All drafts saved; safe to update" for both Android +devices. The APK is 0.1.4+5, 19,861,439 bytes, development signed, SHA-256 +`05100434e1695e6e3e2e8bc7d62bf5230c2f9c32f6e5ee1587f2b73e44504641`, +built from UI checkpoint `7200fe3b` atop the coordinated transport fixes. + +| Device | Actual delivery | Installed identity | +| --- | --- | --- | +| Samsung SM-X906B / R52T60C6V9B | Initial streamed install failed to load/parse its temporary base.apk; one ordinary non-streamed `install -r` succeeded, retaining data. ControllerLauncher started. | 0.1.4 / code 5; installed base.apk SHA-256 matches the deliverable. | +| Samsung SM-S908E / R5CT44LZ42K | In-place install rejected: Requested internal only, but not enough space. 439,388 KiB available; vendor low-storage threshold 1 GiB. No unrelated data/cache was removed and no reserve bypass was used. | Still 0.1.2 / code 3; base.apk retains its prior `e298b2d2...54880df` hash. | + +Both distinct devices were launched through the ordinary ControllerLauncher and +remain locked. Invalid-token ordered Intent requests reached their Dart routers +and returned JSON-RPC `-32001`. This proves rejection/routing, not authorized +transport, visual/touch, native launcher/task, peer APK, audible/segment timing or +physical PWM acceptance. The phone needs available storage before another update; +the continuous improvement goal and all unfinished user acceptance gates remain +active. No full suites, extra APK builds or CI were run for this delivery. + +## Appearance and uncompact follow-up + +The latest requested uncompact layout restores full peer connection cards with +the actual Connected pill and a full-width row for each available chart, including +separate lighting and audio temperature series. Compact mode retains its grid. +Disconnect is menu-only in both layouts; the compact strip also exposes Connect/ +Cancel in its visible More menu so peer names fit. Explicit disconnect still +persists the discovery opt-out. + +Preferences retain the former neutral light/dark surfaces and classic headers as +Pastel (muted), alongside the current Vibrant appearance. Header styles can follow +the selected appearance or use Classic, Icon tile, Accent band or Plain. Real +channel action buttons support Outlined, Soft fill and Solid fill, with the same +ownership, lock, pending PWM and safety guards. All choices persist independently. + +Ten focused appearance/PWM/relay regressions passed; five appearance regressions +were rerun after the compact-menu adjustment. Analyzer and phone/tablet controlled +rendering passed. Renderer images replay the recorded read-only telemetry and +are not deployed-device visual or physical acceptance. Release version is 0.1.5+6; +one ARM64 release build passed from checkpoint `0db47657`. APK bytes: 19,861,303; +SHA-256: `999506a72adfb3271be5815b06210eb963cafecbf0927b4c57c6baa18f3a2935`. + +The SM-X906B tablet updated in place using an ordinary non-streamed `install -r`; +installed version/code 0.1.5/6 and base.apk SHA-256 match this deliverable. The +SM-S908E phone remains 0.1.2/3 with its prior APK hash; current available storage +is 472,384 KiB, below its previously verified 1 GiB vendor low-storage threshold. +No repeat phone install, data removal or storage-reserve bypass was performed. + +Both ordinary launchers were invoked and both invalid-token read-only app.ping +Intent requests reached Dart and returned -32001. The phone remained locked. +The tablet became unlocked during verification, with Termux in the foreground +when its current display was inspected; its live use was preserved. This is not +a deployed Flutter visual/touch test. Authorized RPC/peer flows, physical behavior, +standalone support and human acceptance remain open. The coordinated chat owns +the subsequent semantic relay-color/caption-alignment source follow-up; 0.1.5 is +the delivered appearance checkpoint, not proof of that subsequent requested fix. + +## Front-panel loading scroll stability + +Global command progress now reserves a constant two-pixel slot instead of inserting +and removing a sibling above the scroll viewport. Front-panel loading therefore +does not change viewport geometry, content extent or the current scroll offset. +The animation exists only while a command is pending. + +Two focused phone/tablet widget regressions click a real front-panel widget through +a memory-only deferred RPC, verify exactly one command, and compare viewport, +button position, scroll extent and offset before/during/after acknowledgement. +Both passed; Flutter analysis passed. No real board action was sent. This source +fix is subsequent to delivered 0.1.5 and must be included in the next coordinated +APK; source regression proof is not installed-device acceptance. + +## Outputs organization + +Channel icon/grip targets support handle-only drag/drop with edge auto-scrolling, including responsive +tablet grids. More offers Move earlier/later as a keyboard/accessibility alternative. +Reorder uses one native presentation transaction with expected_revision and the +complete same-kind rank set, including hidden controls. Same-kind channels may +move between folders; mixed hardware kinds and raw-seat folder assignments are +rejected. This changes host presentation metadata, not output state or wiring. + +Tap a caption or use More > Rename for inline editing. Keyboard Done or the +checkmark saves; More > Cancel rename or Escape cancels. The caption/editor share +font, inset and a constant 44-pixel slot. Cancelled, unchanged and stale-owner +edits send no mutation. Applied names/order come from the authoritative full +catalog receipt, with identity-keyed widgets and a fence against older reads. +Uncertain writes are not replayed automatically. + +Fourteen focused Outputs, PWM, progress-scroll and semantic-color checks passed across the focused passes, +including actual handle gestures, delayed acknowledgement, hidden-rank accounting, +owner replacement, cancellation and stale catalog reads. Analyzer and controlled +phone/tablet renders passed. No real output or host metadata was mutated. This +source targets the combined 0.1.6+7 APK, including the prior progress-slot and +semantic-caption fixes. Android delivery and human/physical acceptance are separate. + +A short 1280x400 viewport exposed a separate vertical activity-rail overflow; +that small-height responsive-layout gate remains open. + +### Combined 0.1.6 delivery + +The ARM64 APK built from `884f1a8167d58c474df67dc16b4d40c95f144124` is +0.1.6/code 7, 19,926,839 bytes, SHA-256 +`e4e2548d280aef3a84a70dc7a2062c32a229557518569e1e1ed6f20f3098ac5b`. +Its signature verified with the same inherited Android development certificate. +After the fresh direct confirmation "All drafts saved; update the tablet", one +ordinary non-streamed in-place install succeeded on SM-X906B/R52T60C6V9B. +Its installed version/code and base.apk hash match the deliverable. Data was retained; +the ordinary launcher started and an invalid-token read-only Intent reached Dart +and returned -32001. The tablet was locked at verification, so these receipts do +not prove real drag/rename, peer metadata persistence, visual or physical acceptance. + +SM-S908E/R5CT44LZ42K remained 0.1.2/code 3 with its earlier APK hash and 504,996 KiB +available, below the previously verified 1 GiB vendor low-storage threshold. No +repeat install, data/cache removal or reserve bypass was performed. Its live +unlocked use was left intact. The continuous goal and unfinished acceptance gates +remain active; no full suites or CI iteration was run for this package. + +## MOSFET readback stability + +The reported jumping card exposed a source timing bug: native default status +freshness is 1500ms, but Flutter PWM polling runs every 2000ms. Using the former +to discard PWM readback caused known sliders to become Unknown inputs between +ordinary reads. PWM observations also incorrectly depended on unrelated status +freshness/availability despite a valid direct PWM response. + +PWM now has an independent bounded readback window (at least 6000ms for its poll +cadence). Same-owner late, unavailable or incomplete reads retain the last valid +percentage; Stale/Last known presentation disables edit while keeping slider/card +geometry. Fresh complete readback restores current state. Changed owners and +explicit disconnect clear the ownership-bound cache. Unknown with no observation +still has no fabricated value. Current automation PWM follows its own freshness. + +Ten focused freshness, PWM interaction and transport checks passed; the three +typed-transport checks were repeated after strengthening complete-receipt validation. +Read-only tablet observations showed the app running but display Dozing/black; +the bounded Cafe read-only sampling attempt timed out. Neither is live flicker +reproduction proof. No physical output or host metadata was written. This source +targets 0.1.7+8; package and device identity are recorded after delivery. + +The coordinated chat subsequently recovered the exited Cafe controller through +its user-approved installed recovery task. Eight read-only samples after recovery +confirmed one stable host/generation, PWM available with all 16 channels, no RPC +errors, and 9–16ms read latency. The live peer advertises 1500ms freshness and +500ms status interval, confirming the cadence mismatch. The earlier timeouts were +the stopped host, not proof of PWM-age behavior. No output mutation occurred here. + +One ARM64 build from `19672cf0f06672be6e596d9412f5baa558486d76` passed. APK +0.1.7/code 8 is 19,926,839 bytes, SHA-256 +`348528f8c311b942bae0534940592a3e3d6281a09e2d2c5b9022fb34d7c8a7e9`, +with its inherited development signature verified. Under the recent direct +saved-drafts/update confirmation and standing bug-fix delivery authorization, +one ordinary non-streamed in-place tablet install succeeded. Installed version/code +and base.apk hash match; the ordinary launcher and read-only invalid-token Intent +returned the expected routing/auth rejection. The tablet was locked/Dozing, so +actual on-screen stability still needs human verification. The phone remains +0.1.2/code 3 with 503,148 KiB available; no retry or data removal was performed. + +## Android buzzer timing and waveform + +The user reports missing, clipped, crackly, imprecise and muffled Android mirroring. +The referenced Linux work was read and checked against the current Go receiver. +Its bounded 50ms playout cushion, 250ms fresh-observation rebasing, full-duration +handoff, clock-wrap and immediate Stop policy informed this Android change. + +The old Dart path published a session before native open completed, omitted +startup notes, and subtracted handoff/timer delays from short notes. The old +native singleton replaced burst notes, paused/flushed PCM on every transition +and wall-clock expiry, and could discard non-blocking short transfers. Its +sine-only waveform and 3ms ramps also attenuated very short beeps. + +Android now receives ahead-of-time note scheduling on a persistent audio-frame +timeline. Startup bursts wait for one receiver open; fresh late notes/followers +shift equally with their original duration, while stale queued receipts expire. +PCM duration uses frame counts, including 48 frames for a 1ms note at 48kHz. +Partial writes retain the original tail/phase/envelope. Ordinary transitions +do not flush; explicit Stop, lost owner/focus/lifecycle and the independent +six-second lease still cut off sound. Temporary focus ducking reduces volume +and retains the receiver. A PolyBLEP square wave adds brighter harmonics with +0.25ms bounded edge ramps and headroom instead of a sine-only sound. + +Thirteen focused Dart current/legacy mirroring tests and seven native PCM tests +passed, including delayed open, 1/3ms notes, clock wrap, stale startup, Stop/open +and owner races, delayed cadence, partial writes/chunk continuity, queue bounds, +silence, bounded integer conversion and harmonic/no-clipping checks. These are source/synthetic evidence, +not a measurement of Android route latency, real peer packet loss or speaker +acoustics. The selected-board bridge-source fence remains; explicit routed +source ownership is still needed for unrelated bridged-board playback. +This source targets 0.1.8+9. Device delivery and human listening are recorded +separately; no physical board tone, relay, motor or firmware action was sent. + +One ARM64 build from db8bcb7b passed. APK 0.1.8/code 9 is 19,926,839 bytes, +SHA-256 fc40ac2748c6bb241ca273e1bc6083d469cf6907294e5ca7cb1714370c88b57f; +its inherited Android development signature verified. One ordinary non-streamed +in-place tablet install succeeded under the recent saved-drafts/update confirmation +and continuing bug-fix delivery authorization. Installed version/code/base.apk +hash match; the ordinary launcher and read-only invalid-token Intent passed +routing/auth-rejection checks. The tablet remained locked, so no actual audible +receiver/peer/speaker acceptance is claimed. A listening comparison was requested. +The phone remains 0.1.2/code 3 with 500,792 KiB free, below its prior verified +1 GiB installer threshold; no repeated install, data removal or reserve bypass. +The continuous goal stays active. No full inherited suite/CI iteration was run. + +## Pull-to-refresh + +All main activity views now support a downward pull at the top, including +content too short to scroll. A full-height scroll viewport and overlay spinner +preserve layout, scroll position and the fixed global progress slot. Refresh +uses selected PCController/Pealayer readback; Firmware additionally reads its +artifact catalog and current update status. Network and offline refresh scan +nearby peers without auto-connecting or changing saved connection intent. +Unsaved text remains intact, and read failures use an overlay SnackBar. + +Concurrent refreshes join the same current-owner read/discovery future. A +disconnect retires the old read without blocking or overwriting its replacement. +Existing catalog revision, PWM-write fencing and command ownership remain. +Pulling never sends a hardware action, prepares/downloads/uploads firmware, +retries a mutation or changes a selected connection. + +Six focused refresh checks and both existing phone/tablet front-panel progress +geometry regressions passed. They cover actual pull gestures on short pages, +coalesced reads, response waiting, read failures, draft preservation, replacement +owners, read-only firmware RPCs and discovery without connection. This source +targets 0.1.9+10; live device gesture acceptance and delivery are recorded separately. + +Three PWM ownership/readback guards and one older-catalog fence check also +passed, making twelve relevant checks in this pass. Final analyzer and one +ARM64 build passed. APK from 0d978d6d is 0.1.9/code 10, 19,992,375 bytes, +SHA-256 65c380a077f69c915b6b9b9f793ad3af307ed43b97f0d2940e680a4b28b79e23, +with the inherited Android development signature verified. One ordinary +non-streamed in-place tablet install succeeded; installed version/code/base hash +match. The tablet's foreground video app was preserved, so the controller was +not launched over it and live gesture acceptance remains pending. +The phone remains 0.1.2/code 3 with 499,652 KiB free below its previously verified +1 GiB installer threshold. No retry, data removal, physical action or firmware +operation was performed. Concurrent generated Web outputs were left untouched. + +## PU850 endpoint and dialog import, batch 1 + +The full 5,429-line MainActivity and active/inactive non-business network/UI +helpers and resource variants were reviewed without modifying or running PU850. +PU850-IMPORT-LEDGER.md records findings, exclusions, source digests and U01–U14 +phased migration statuses. The first batch adds localized address normalization, +modern host/URL/IPv6/port validation, inline errors and canonical preview, +bounded same-host product detection and explicit offline targets, responsive +connection/confirmation dialogs and selected-peer native-network reconnect wakes. +Opening attempts coalesce; explicit disconnect, owner fences, typed relay +routing and command non-replay remain intact. Pastel/Vibrant styles are retained; +PU850 Droid Sans Mono is ported with its original digest and bundled license. + +Twenty-five focused checks and one source-renderer check passed; analyzer clean. +Phone/tablet front-panel busy geometry checks still pass. Three modal PNGs are +source fixtures only. Read-only LAN probes to the previous Cafe :8787/:8080 +addresses timed out; live endpoint/reconnect behavior and human design acceptance +remain open. No physical action or PU850 business command was sent. Source +targets 0.1.10+11; package/device identity is recorded separately after delivery. + +## Batch 1 package/device receipt + +One ARM64 release build from c561c207edf75bfc347b6bcda24ee1249e50cb9c passed. +APK 0.1.10/code 11 is 20,133,328 bytes, SHA-256 +`3e40eda6c0d36589ebe04081d46dc578464555eee40592662aab2fbb97e4593d`. +Its inherited Android development signature verified. One ordinary non-streamed, +in-place tablet install succeeded under the existing saved-drafts/update and +continuing delivery authorization. Installed version/code/base APK hash match. +The tablet's active video foreground was preserved; no controller launch or +physical output/audio/firmware action was sent. Live interaction is still open. + +The deduplicated SM-S908E phone remains 0.1.2/code 3, with 499,840 KiB available +below its previously verified 1 GiB installer threshold. No repeated install, +data removal, reserve bypass or unlock was attempted. The continuous acceptance +goal remains active. android-0.1.10-delivery.json records exact identities and +source/device/physical acceptance separately. + +## Latest contextual utility rejection + +The complete requirements are retained in root/mobile AGENTS.md and the scoped +source/device audit is CONTEXTUAL-UTILITY-AUDIT.md. This wave targets 0.1.11+12: +one lifecycle connection toggle, contextual effect cancellation and live cards, +clickable guarded relay indicators, balanced full-width action rows, kind-scoped +folder dropdown/dialog, event-first readback and only degraded-transport polling. +Thirty focused checks and one source renderer passed. Native Cafe directory and +Pealayer active-protocol readbacks are recorded separately from Android multicast, +OS launcher/branding, peer install, physical/audio and human acceptance. + +## Package/device receipt + +Final analyzer and one ARM64 release build passed. Source checkpoint +0eef88bb5736b7010efa990fc65278c0531641de produced 0.1.11/code 12, +20,133,536 bytes, SHA-256 +`0af60c9b04f34964f19a0f072cd87df13fb6decd3bf585789bb9647c2986ba37`. +The inherited Android development signer verified. One ordinary non-streamed +in-place tablet install succeeded under continuing delivery authorization and +the recent saved-drafts/update confirmation. Installed version/code/base hash +match. Active video foreground remained intact; the controller was not launched +over it. No physical relay/motor/beep/firmware or player action was sent. + +The deduplicated phone remains 0.1.2/code 3, with 498,272 KiB available below +its previously verified 1 GiB installer threshold. No repeated install, data +removal, reserve bypass or unlock was attempted. Exact receipts are in +android-0.1.11-delivery.json. Live Android discovery, OS branding/launcher changes, +two-device upgrade/installation consent, full egui parity and physical/audio/human +acceptance remain open. The continuous goal remains active. + +## Go help and usage parity wave + +0.1.12+13 adds Controller tools, with 50 useful advertised native command families +and dedicated settings/name/display/lighting/routing/melody/recording/RF forms. +GO-FLUTTER-PARITY.md records the help/runtime provenance, parser findings, exact +native limits, dedicated versus workbench/platform scope and remaining gaps. +Owner/E-STOP/uncertain fences, priority Stop, reviewed release and typed-relay +third-peer exclusions apply. EEPROM preflight conflicts retain edited drafts; +native CAS remains a gap. Native acknowledged-but-unconfirmed PWM stays connected, +retains stale last values and never retries. Expert PWM commands share read fencing. +Tools have no periodic polling; entry/explicit refresh reads preserve drafts. + +42 focused source checks, three tools/dialog widget checks and one phone/tablet +source renderer passed; analyzer clean. Existing front-panel progress and pull +refresh viewport checks remain passing. Controlled renders are not device screenshots. +No physical relay/motor/beep/RF/firmware/player action was sent. Final package/device +receipt follows separately. Continuous acceptance goal remains active. + +## Go parity package/device receipt + +One ARM64 release build from 1f9f2b2071d754222834da3d87b1cec3afa09547 +produced 0.1.12/code 13, 20,266,596 bytes, SHA-256 +`e124b356e95c41765636791c61e75b4347db9d14663ee44bf7094f67edfbb5d8`. +The inherited Android development signer verified. One ordinary non-streamed +in-place tablet install succeeded; version/code/base APK hash match. The tablet +is locked and retains its observed video activity/task; no controller launch +replaced it. No physical output/audio/RF/firmware/player action was sent. + +The deduplicated phone remains 0.1.2/code 3, with 491,588 KiB available below +its previously verified 1 GiB installer threshold. No repeated install, data +removal, reserve bypass or unlock was attempted. android-0.1.12-delivery.json +records current identities and open gates. Source/render/build/delivery are +separate from live physical/audio/human acceptance. Continuous goal active. + +## 0.1.13 live value wave + +See ../docs/WS-LIVE-VALUES.md for the publisher and known-client audit. Flutter regression checks cover complete calibrated PWM push, old owner/revision rejection, stale retention, no extra HTTP read on healthy complete events, recovery and stable Front Panel scroll. Candidate deployment and phone storage are verified per device; no physical acceptance claim follows source or package checks. + +Delivery observed 2026-10-13 06:40 UTC: 0.1.13/code14 ARM64 release, source b07b9cad8a69f1128bb6abc79741927ca6e45f05; SHA256 107ffd598ef664747063382f4e45743ffdbe6d4d9b7e301735c6b9e665ee3ceb, 20,266,596 bytes; inherited development signer verified. SM-X906B R52T60C6V9B ordinary background in-place install succeeded; package/code/base APK hash verified and locked video task preserved. SM-S908E R5CT44LZ42K remains 0.1.2/code3 with 489,932 KiB free and Android LOW state; prior insufficient-space rejection still applies, no repeated identical installation/data removal. Both distinct reachable serials inventoried with ADB/mDNS and duplicate phone advertisements deduplicated. Human asked to free phone storage to at least 1 GiB. No new controller launch under the locked foreground task, production native replacement, physical actuation, flash or audio acceptance. Reviews: PCController #660, Pealayer #178. Exact device receipt is saved with the delivered APK. + +## 0.1.14 UI closure wave + +UI-REQUIREMENT-LEDGER.md maps the durable requirements to current UI/source proof and remaining live/human gates. The new wave addresses navigation/compact peer composition, appearance previews, effect folder assignment, keyboard-aware cue/effect/color dialogs, native numeric limits, invalid/unapplied draft preservation, unique local drafts, media URL validation and contextual empty cards. 44 focused functional checks plus one opt-in renderer pass; analyzer clean. Existing output/Front Panel/pull-refresh/chart/theme/connection fences pass. No physical action. Candidate is 0.1.14+15; delivery follows the source checkpoint. + +Delivery observed 2026-10-13 08:14 UTC: final 0.1.14/code15 ARM64 release from 55c747dda405eb16d155c0a32be8c0aaee556a96; 20,266,808 bytes, SHA256 7256efa4d6f143dde1104fc2ddc3871fd76dc7db6adf06e42d5555a2af9879e7; inherited development signer verified. Earlier pre-guard build was superseded before delivery. One ordinary non-streamed in-place tablet install succeeded; SM-X906B R52T60C6V9B installed package/version/base hash verified, locked video activity/task retained. SM-S908E R5CT44LZ42K remains 0.1.2/code3 with 402,076 KiB available below its 1 GiB vendor installation threshold; Android LOW state persists, no repeated identical install, data deletion or reserve bypass. Both reachable distinct serials inventoried; duplicate phone mDNS advertisements deduplicated. No controller launch over the tablet video session and no physical output/audio/firmware/player action. Physical, audio, Android IME/launcher/discovery/transport and human acceptance remain open. PR #661 contains this UI topic stacked on #660. diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md new file mode 100644 index 000000000..46e5afb17 --- /dev/null +++ b/mobile/AGENTS.md @@ -0,0 +1,30 @@ +# 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. + +- Keep GO-FLUTTER-PARITY.md current when porting Go help/usage. Distinguish dedicated Flutter forms, advertised native workbench access and platform-only workflows. Use native validators for actual limits. Tools read catalogs only on entry/explicit refresh and preserve drafts. Owner/E-STOP/uncertain no-replay rules apply to expert commands; priority Stop remains available during requests and release requires review. EEPROM edits preserve programming/opaque flags and reject a changed preflight baseline while retaining drafts; never call this atomic CAS. CLI PWM shares typed readback fencing. Acknowledged writes without confirmation retain stale data, never disconnect/retry. Strip effects use stable IDs with human captions; recording requires a name and RF/display/name constraints follow native code. + +- 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. The latest direct request uses one compact lifecycle toggle: Connect, Cancel while connecting, or Disconnect when connected. Never show competing connection verbs together. Keep explicit disconnect discovery opt-out and Connect/Cancel lifecycle handling. 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. +- MOSFET/PWM edits retain their drag and pending setpoint until the guarded command and fresh authoritative readback finish. Fence pre-write reads and changed owners; never replace a pending edit with an older observation, invent zero, claim optimistic applied state or retry uncertain actuation. +- PWM has a readback freshness budget consistent with its polling cadence, independent of fast status telemetry. Retain same-owner last good readbacks through expiry/unavailable/incomplete reads, marking them Stale and disabling edit while preserving slider/card geometry. Only a changed board/host/connection clears ownership-bound values; unknown with no observation is never zero. Automation exposes current PWM only when its own readback is fresh. +- Preferences persist relay-card layout: compact one-line controls by default, or two-line cards with explicit ON/OFF actions. Both use the same observed state, channel ownership, lock and safety guards. +- Relay ON/OFF actions use green/red semantic colors, not the branding/accent seed, across filled/tonal/outlined styles and compact/menu controls. Disabled actions remain muted. Card captions, action labels, fact rows and state pills share centered text leading; desktop inline captions/editors must keep matching font/inset/slots. +- Uncompact Live restores full connection cards with an actual Connected/Host only/Connecting/Offline pill, including the first PCController card. Each available telemetry series gets its own full-width chart row, including separate lighting and audio temperature charts; compact mode retains its metric grid. +- Global request progress reserves its layout space in idle and busy states. Front-panel actions and other loading transitions must not move the scroll viewport, change its extent or reset its position. +- Pull-to-refresh works at the top of every main activity, including short content. Its indicator overlays a full-height scroll viewport; it never inserts rows or clears authoring drafts. Refresh joins current-owner in-flight reads, discards replaced-owner results, and only reads selected peer state/catalog/PWM plus contextual firmware status/catalog. Network/offline refresh may scan nearby peers without automatic connection or changed saved connection intent. Failures remain actionable without resizing the page; no actuation, mutation replay or firmware preparation/download/upload is part of refresh. +- Outputs support handle-only drag/drop reorder and inline caption rename using the native revision-checked presentation RPC, not device-only ordering. Preserve hidden ranks, output-kind and raw-wiring constraints, identity-keyed card/editor state and authoritative receipts. Cancel/unchanged edits send nothing; stale owners or revisions cannot receive an edit, and older catalog reads cannot overwrite acknowledged presentation. Inline edit shares the caption font, inset and fixed height. +- Retain previous neutral surfaces, muted accent palette and classic card headers as the Pastel (muted) appearance option; retain the current appearance as Vibrant. Persist independent header and actual action-button styles. Appearance changes must preserve indicators, accessibility targets, disabled safety state and command ownership without any hardware action. +- Buzzer mirroring preserves fresh short-note durations and device cadence with bounded startup buffering and ahead-of-time native audio-frame scheduling. Follow the Linux 50ms playout/250ms freshness policy; stale observations and changed owners never replay. Ordinary transitions/endings never pause/flush queued PCM; explicit Stop and lifecycle/focus/lease cutoffs do. Retain partial AudioTrack writes, use a band-limited passive-buzzer-style waveform with short edge ramps, and handle temporary focus ducking without dropping the session. PCM/source tests and installed APK identity do not prove audible fidelity on an Android speaker. + +- Preserve the phased PU850 imports in PU850-IMPORT-LEDGER.md. Connection input normalizes localized digits/separators, admits explicit modern HTTP(S)/WS(S)/IPv6 endpoints and validates ports, credentials and known service paths inline. Read-only detection verifies the requested product on bounded same-host candidates, never forwards credentials or follows redirects, and preserves explicit port/TLS intent. Edits/cancel retire probes; saved offline intent is explicit. Native network recovery only wakes the selected enabled peer, coalesces opens, preserves request/response-only relay routing and never replays commands. Shared dialogs retain explicit Close/Cancel, keyboard-safe scrolling, accessible targets and stable progress/error slots. Keep the imported font license/notice and separate fixture rendering from live Android acceptance. + +- The latest contextual-utility rejection remains authoritative: no idle effect Cancel/Stop actions, empty live-only cards, unavailable-only fact cards or static "last values unavailable" narration. Keep actionable exceptions and useful offline editors. Hardware controls use balanced equal-width action rows within compact cards, semantic ON/OFF colors and directly clickable observed relay indicators with the same current-owner/lock/E-STOP gates. Folder assignment is a catalog-driven dropdown scoped to output kind and preserving raw folder identity, not a plain text field. Refresh is explicit and event-driven; only failed/stale/unsupported WebSocket transport uses bounded fallback reads. Preserve acknowledged PWM drafts, current profile-resolved names, and owner/revision fences. Native network discovery, auto-connect intent, branding/task icons and dual launcher aliases, all existing color schemes and integrity-checked peer IPC/RPC app transfers remain acceptance requirements. Defer remote multi-step authentication until supported; hide unused remote token fields without weakening protected local automation. Every new surface follows this contract without another user reminder. + +- Live continuous values retain a quiet final update per domain; do not drop it through throttling or collapse RGB/PWM into one shared latest slot. Complete calibrated push observations carry selected host, board generation, monotonic revision and time. Buffer bounded notifications until subscription ACK; fence replaced owners and older reads, and preserve active edits/acknowledged setpoints. Healthy complete pushes must not trigger per-client HTTP/PWM polling. Audio/segments/lifecycle stay immediate. Typed request/response relay must retain honest bounded fallback rather than treating player WS as a controller subscription. Record Android installed identity per reachable device separately from source, physical, audio and human acceptance. + +- Apply the folder-dropdown contract to effect/Studio authoring as well as hardware assignments. Preserve current raw category identity; creating a new folder is a separate explicit action. Effect/cue/settings dialogs share bounded keyboard-aware chrome, explicit Close/Cancel and fixed busy progress space. Invalid numeric edits survive selection changes and block saving; never silently retain the old number behind invalid input. Unapplied technical JSON must not be overwritten by typed controls. Pending saves disable editable fields; failures preserve drafts and unchanged Save sends no mutation. Resource URL normalization changes authority characters only, retaining native paths, query and Unicode filename semantics. Keep mobile/UI-REQUIREMENT-LEDGER.md current and never mark all UI/hardware accepted from source fixtures or delivery. diff --git a/mobile/AUTOMATION.md b/mobile/AUTOMATION.md new file mode 100644 index 000000000..3f1d4a747 --- /dev/null +++ b/mobile/AUTOMATION.md @@ -0,0 +1,120 @@ +# Native-local automation (preview 0.1.2) + +Enable **Settings → 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 `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. + +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. + +Front-panel methods use the same `expected_target` and separately enabled +actuation fence, with host admission still authoritative: + +| Method | Parameters | +| --- | --- | +| `board.menu.press` | `button`: `prev`, `next`, `dec`, `inc` in normal mode, or `K1`..`K4` when HOST captured | +| `board.display.show` | `display`: `segments` or actually available `lcd`; nonempty `text` up to 240 characters; optional `duration_seconds`: 1..30, default 5 | +| `board.buzzer.play` | integer `frequency_hz`: 20..20000; integer `duration_ms`: 1..1000 | +| `board.buzzer.stop` | Stops device mirroring and requests host melody Stop; works without actuation opt-in and under E-STOP | +| `board.mode.set` | `mode`: `idle` or `running` | + +These methods are shared by HTTP, WebSocket, NDJSON/TCP and Android Intents. +They accept no arbitrary command text, reset/flash/E-STOP release or shell +execution. `app.capabilities.board_methods` advertises the implemented list. + +`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/CONTEXTUAL-UTILITY-AUDIT.md b/mobile/CONTEXTUAL-UTILITY-AUDIT.md new file mode 100644 index 000000000..fc5a1c8bc --- /dev/null +++ b/mobile/CONTEXTUAL-UTILITY-AUDIT.md @@ -0,0 +1,88 @@ +# Contextual utility redesign audit + +The 2026-10-13 rejection is durable in root and mobile AGENTS.md. The latest +explicit single Connect/Cancel/Disconnect toggle supersedes the earlier menu-only +Disconnect request. This document records source proof separately from delivery, +real transports, physical behavior and human design acceptance. + +## Changes in 0.1.11+12 + +- One compact connection lifecycle toggle per peer; no competing connection verbs + in More. Explicit disconnect still persists discovery opt-out and saved intent. +- Cancel playback exists only for authoritative running macro/effect IDs; Pealayer + Stop effects requires its advertised hardware_details.macro_running. Empty + live-only panel/board/effect cards and static empty-catalog narration disappear. + Useful offline drafts and retained stale PWM edits remain, with owner guards. +- Relay state pills directly invoke the opposite typed action, using actual + observed state and the same ownership, lock, busy and E-STOP checks. E-STOP is + checked again after any asynchronous confirmation. Unknown never implies OFF. +- Hardware action rows have equal widths and fill their cards. Grids fill their + available width and reduce column count for small groups. Semantic green/red + ON/OFF colors, muted disabled actions, header menus, inline rename, handle-only + reorder, independent header/button styles and Pastel/Vibrant stay intact. +- Channel folder assignment is a dropdown with No folder, compatible advertised + folders and same-kind existing assignments. Current raw identity is retained. + Channel editing now uses the shared responsive dialog header/Close/footer. +- A healthy acknowledged WebSocket subscription performs no periodic HTTP/PWM + reads. Change events coalesce readback; explicit pull refresh is retained. + Same-owner confirmed PWM stays current through a continuous fresh stream; + failed/unavailable or replaced-owner reads cannot become a zero or an applied + draft. Failed/stale WebSockets and request/response-only relay routes use bounded + fallback reads. Reads join in-flight work, and commands never replay. +- The short tablet activity rail scrolls instead of overflowing. app.ping no + longer advertises the obsolete hardcoded 0.1.7 version; native package.info + remains the installed package identity source. + +## Existing cross-project capabilities inspected + +| Requirement | Source evidence | Current acceptance | +|---|---|---| +| Unified activities | workspace_chrome.dart Live/Channels/Panel/Studio/Devices/Firmware/Events; one selected board/player context | Phone/tablet fixtures; human acceptance open | +| Palettes/branding | preferences.dart Digitalogic/Pealayer/Pakar/Amber/custom, Pastel/Vibrant; setTask native task title/icon/color | Source inspected; actual OS switching open | +| One/both launchers | Android manifest aliases target one MainActivity; PreferencesChannel enables destination before disabling old aliases with DONT_KILL_APP | Source inspected; real launcher acceptance open | +| Android discovery | NsdManager DNS-SD/mDNS service browse/resolve, multicast lock/network callback; discovery.dart SSDP/MNDP parsing and read-only identity verification | Parser/source proof; real Android multicast open | +| PCController discovery | internal/discovery DNS-SD/mDNS, SSDP and MNDP; public directory product/ports; hostbridge advertises configured protocols | Cafe public directory read below; transport-by-transport live acceptance open | +| Pealayer discovery | server/discovery.rs browse/advertise/verify DNS-SD/mDNS, SSDP and MNDP | Cafe reports all four active below | +| Automatic connection | scanPeers requires verified unambiguous role, auto-connect preference, no current selected connection and no explicit disconnect opt-out | Existing saved-intent/owner guards retained | +| APK peer transfers | AppUpgrade bounded chunk IPC/RPC read/write, size/digest/package/signer/newer-version checks; PeerUpgrades foreground sharing and short-lived approved offer | Source inspected; two-device transfer/install consent and phone storage open | +| Authentication | Remote token inputs absent; future multi-step authentication deferred. Protected local HTTP/WS/TCP/Intent automation keeps required credentials and action permission | Existing local security preserved | +| egui hardware parity | Pealayer layout.rs 7375/7415/7575 observed indicators, direct relay toggle and card actions informed this change | Full native parity, hold-motion/shortcuts and physical acceptance still open | + +Read-only Cafe checks through its existing SSH endpoint reached both native +services. PCController /upnp/public.json returned product PCController, +health.ok true and enabled discovery protocols dns-sd, ssdp, upnp, +ws-discovery, broadcast, netbios and mndp. This is a configured directory, +not proof that every multicast transport succeeded. Pealayer /api/discovery/peers +returned configured dns-sd/mdns/ssdp/mndp, all four active, and four peers. +No connection selection, metadata mutation, board output, playback or firmware +operation was sent by these checks. Earlier failed direct LAN probes are not +reinterpreted as successful Android discovery. + +## Verification + +Thirty focused checks passed: 17 appearance/recovery/front-panel/output checks, +five new contextual checks, one real loopback healthy-WS/event/fallback check, +six PWM ownership/freshness checks and one discovery parser check. One additional +opt-in renderer passed and produced phone/tablet hardware and folder-dialog PNGs +using actual app themes/fonts. Those are controlled source fixtures, not device +screenshots. Analyzer/package/device receipts are added after final delivery. +The application remains unaccepted until its live and human gates are met. + +## Package/device receipt + +Final analyzer and one ARM64 release build passed. Source checkpoint +0eef88bb5736b7010efa990fc65278c0531641de produced 0.1.11/code 12, +20,133,536 bytes, SHA-256 +`0af60c9b04f34964f19a0f072cd87df13fb6decd3bf585789bb9647c2986ba37`. +The inherited Android development signer verified. One ordinary non-streamed +in-place tablet install succeeded under continuing delivery authorization and +the recent saved-drafts/update confirmation. Installed version/code/base hash +match. Active video foreground remained intact; the controller was not launched +over it. No physical relay/motor/beep/firmware or player action was sent. + +The deduplicated phone remains 0.1.2/code 3, with 498,272 KiB available below +its previously verified 1 GiB installer threshold. No repeated install, data +removal, reserve bypass or unlock was attempted. Exact receipts are in +android-0.1.11-delivery.json. Live Android discovery, OS branding/launcher changes, +two-device upgrade/installation consent, full egui parity and physical/audio/human +acceptance remain open. The continuous goal remains active. diff --git a/mobile/GO-FLUTTER-PARITY.md b/mobile/GO-FLUTTER-PARITY.md new file mode 100644 index 000000000..d3742a93d --- /dev/null +++ b/mobile/GO-FLUTTER-PARITY.md @@ -0,0 +1,96 @@ +# Go edition to Flutter parity ledger + +2026-10-13; candidate 0.1.12/code 13. Dedicated Flutter forms, native command +access and platform-only workflows are distinct; this is not full standalone +or physical acceptance. + +## Sources and findings + +Only the packaged controller's early-exit `help` and `version` were executed. +Its source is 6064d41cd9b1561497dd7d114421a7fde1ebb76a3d25c91e36f8403e6ab6c7bc, +built 2026-10-12, older than current canonical source. Cross-checked top-level +help/dispatch in cmd/controller/cli_support.go and main.go, the current command +registry, native payload validators, display/settings/recording/RF/strip/effect +parsers and typed IPC endpoints. Read-only live Cafe controller.command.catalog +returned 52 definitions; the committed fixture preserves this snapshot. Flutter +exposes 50 useful families, excluding remote console clear/quit. Runtime uses the +selected host's actual advertised catalog. These are families, not 50 individual +features. Usage, aliases, groups and summaries are searchable. + +Findings corrected in Flutter: + +- Display previously allowed 240 characters and fixed five-second playback. + Actual typed native bounds: 40 printable ASCII bytes, segment/LCD/both, + scroll speed 80–5000 ms, duration 80–65535 ms, once/loop/interval repeats; + intervals are 1–255 whole seconds. Empty text clears the override. +- Board names are eight printable ASCII characters; recording requires a + printable ASCII name up to 64 characters. Automatic, MCU-timed and board-retained + recording are distinct. RF timed mode is `timer`, not `timed`. +- EEPROM settings have 17 ordered semantic arguments. Actual status color is + 0–4 despite broader help. Decimal encoding 0 means two places, not zero. + Programming latch/opaque flags must survive edits without guessed defaults. +- Strip programs require stable IDs, with human names as captions. Pixel/FPS + ranges and modes come from hardware metadata. Native board/host buzzer routing + is separate from Android PCM mirroring preferences. +- Native PWM -32010 confirmation_unavailable/write_acknowledged/retryable:false + means an acknowledged write lacks confirmed readback. Preserve stale observations; + do not disconnect or replay the write. + +## Port inventory + +| Go help/catalog scope | Flutter implementation | Remaining scope | +|---|---|---| +| TUI/Web/shell/exec/catalog | Unified workspace; Controller tools with searchable usage/aliases and explicit one-command workbench | No embedded Go executable/remote console lifecycle | +| status/event/voltage/current/temp/stream | Existing live telemetry/charts/events and explicit refresh; native options in workbench | Actual hardware acceptance | +| relay/PWM/E-STOP | Observed indicators/equal-width controls; priority Stop and reviewed release; CLI PWM shares draft/readback fences | Physical feedback/mixed-client soak | +| front-panel/menu/display | Existing panel/buttons/segments; menu pages/layout reads; new segment/LCD/both timing, repeats, intervals, scroll and clear form | Physical panel/display fidelity | +| settings/board name | Full live EEPROM editor/export and eight-byte name form | Atomic native CAS and physical persistence proof | +| RGB/strip | Capability-driven RGB color/brightness/Auto; strip solid/pixel/frame/rainbow/effect/count/FPS/clear/active-only Stop | Effect editing remains workbench/Studio; physical strip proof | +| buzzer/silent/melody/audio | Native routing/silence targets, melody library/repeats/active-only Stop; existing Android PCM mirror; other audio commands in workbench | Android audible cadence/timbre acceptance | +| macro/effect | Existing sequence editor/catalog/playback; named Automatic/MCU/board recording, active Save/Discard/status; remaining native authoring/property/import/export commands in workbench | Full visual editor parity/physical timing | +| RF/automation/board-automation | Capability-gated 30-second RF learning/catalog/cancel; host/board rule reads; other forms in workbench | Rich RF/rule editors and offline-board proof | +| ports/discovery/open/close/reconnect/hello/port-owner | Existing peer discovery/connection; serial ports/ownership quick reads; serial operations in workbench | Android USB standalone core | +| OS/host-menu/hotkeys/keyboard/webhook/config | Host facts/policy/hotkeys/webhooks quick reads; advertised options in workbench with mutation review | Dedicated visual editors/platform acceptance | +| program/program-state/boot/driver/toolchain | Existing Firmware operation/artifact/backup workflow; advertised native command options in workbench under receiver safety | Top-level provision-host/runtime/service installers are not inner RPC commands; no Android Windows-driver installer | +| I2C/reset/query/opcode/write | Expert workbench with current-owner/E-STOP checks and explicit mutation review | Native authorization/backup/exclusion mandatory; no physical/raw-write tests | +| bridge/peer-update/IPC/WebSocket/monitor | Existing typed Pealayer relay, protected local Android automation and peer package transfers; direct-native bridge/update commands in workbench | No recursive third-peer proxy; two-device consent, ADB/Termux/Tasker/root/API acceptance | +| batch/file-only eeprom/firmware/service/runtime | Existing Studio sequences; native host retains file/lifecycle tools | No automatic CLI batch replay or independent Android native-core execution | + +## Execution contract and proof + +Tools bind to the selected host/board generation and transport. Catalog reads +occur once on entry and on explicit refresh, with no periodic tools polling. +Refresh preserves staged values. Native receiver policy remains authoritative; +catalog entries are not permission to bypass it. Ambiguous forms require manual +review. Cancel sends nothing. Commands dispatch once; uncertain/replaced-owner +receipts cannot become applied proof or replay. Native compile/boot/reconnect +workflows need no normal-mode board at Flutter admission; native recovery gates +still apply. Priority Stop remains available while tools are busy. Release needs +review; other writes recheck owner/E-STOP after dialogs. Typed Pealayer routes +reject bridge/peer-update commands that would become a recursive third-peer proxy. + +PWM writes fence pre-write reads, retain the last observation as stale and schedule +one post-command read. Acknowledged-but-unconfirmed native errors preserve the +peer connection and do not replay. Settings review exports current settings and +compares the semantic baseline before writing; conflicts keep the draft open. +This is not atomic CAS: native settings-set has no revision token, leaving a +race between preflight and write as a backend gap. Programming/opaque flags stay +preserved. + +42 focused command/PWM/appearance/contextual/front-panel/pull-refresh checks and +three new tools/dialog widget checks passed. One opt-in source renderer produced +actual Flutter phone command/tablet lighting fixtures without overflow, not +Android screenshots. Analyzer clean. No physical relay/motor/beep/RF/firmware/player +action was sent. Package/device identities are recorded separately in ACCEPTANCE.md +and delivery receipts. Full Go/egui/Web parity, native Android standalone core/USB, +real command transports/multicast/branding/launchers, two-device upgrades and +physical/audio/human acceptance remain open. Continuous acceptance goal active. + +Package delivery: source 1f9f2b2071d754222834da3d87b1cec3afa09547 built +0.1.12/code 13 (20,266,596 bytes; SHA-256 +e124b356e95c41765636791c61e75b4347db9d14663ee44bf7094f67edfbb5d8). +Signer verified; one background tablet install succeeded and installed identity +matches. Locked video activity preserved, controller launch/live proof open. +Phone remains 0.1.2/code 3 with 491,588 KiB free; no repeated failed install. +Exact device observations: android-0.1.12-delivery.json. Source branches may be +reconciled separately; the APK identity remains tied to the exact build source. 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. diff --git a/mobile/PU850-IMPORT-LEDGER.md b/mobile/PU850-IMPORT-LEDGER.md new file mode 100644 index 000000000..69a5e6c35 --- /dev/null +++ b/mobile/PU850-IMPORT-LEDGER.md @@ -0,0 +1,146 @@ +# PU850 non-business import ledger + +Source: the user-provided local PU850 checkout. Reviewed on 2026-10-13 without +modifying, building, launching or installing the PU850 project. + +MainActivity.java was read in full, all 5,429 physical lines (including comments +and blank lines), SHA-256 prefix de5c45e329c15fe9. The first count of 4,262 +excluded blank lines and is not the physical line count. Its complete read +covered 1–520, 521–1250, 1251–2050, 2051–2870, 2871–3720, +3721–4560 and 4561–5429. + +The supporting review covers active HttpRequest, FileDownloader, SocketClient +and SocketServer; the non-business state/icon portions of CommonDefine; both +layouts; base/night/v27 style variants, colors, dimensions and localization +resources; manifest/build configuration; and the inactive StorageUtils/WebServer +prototypes. Resource variants were compared against their fully read base +files. The source inventory/digests are recorded separately in PU850-source-inventory.json. +Its 75 text entries include inventory-only vector/assets; this is not a claim +that every decorative vector shape was read. All MainActivity and non-business +helper code, including the inactive prototypes, was reviewed. + +## Findings + +PU850 combines scale/receipt/access-level protocol code and generic Android UI +plumbing in one activity. Its useful details are localized-digit conversion +(987–1006), LTR monospace addresses, strict host/port admission, inline field +errors and keyboard submit (1259–1754), native network callbacks (1773–1794), +two-second availability rechecks, a TCP probe followed by application readback +(3109–3255), explicit reconnect intent and WebSocket callbacks (4430–4645). +SocketClient uses a ten-second lost-connection timeout and a cancelable connection +dialog. A successful TCP handshake alone is not PU850 identity or board readiness. + +The dialog title helper (931–983) combines an icon, title and explicit Close. +Dialog/bottom-sheet resources share sixteen-pixel rounding, light/dark surfaces +and localized type. Address, transfer and receipt dialogs keep invalid input open +and route keyboard Done/Enter through submission. File transfer UI uses actual +byte totals, cancellation and throttled progress/notification updates. MediaStore +export supplies useful Open/Share actions after an actual saved file. + +Important adaptation boundaries: the old address regex is IPv4/DNS-only, +requires a port and always constructs HTTP/ws. It rejects full URLs and IPv6. +The port probe excludes 65535 although the input validator accepts it. Socket +and HTTP helpers hold Activity references and manipulate dialogs from callbacks; +AsyncTask, legacy storage reflection, unbounded bulk reads, generic redirect +following and task-clearing locale restarts should not be copied. +The inactive WebServer references another app's PlayerActivity and exposes a +generic command/file server; it is not a working PU850 Android RPC layer. +The beep handler plays notifications/ringtones, not frequency/duration-driven +passive-buzzer audio. Keep the new bounded PCM mirror. + +The Flutter transport already had strict origin validation, but its active +connection field exposed errors only after submission in a SnackBar, had no +localized normalization or endpoint/product detection, and accepted no bare +host in that UI. Existing reconnect backoff and owner fences must be retained; +the prior Android network callback woke discovery, not a selected peer's +delayed reconnect. Network recovery must never replay commands or change peers. + +## Import batches + +Status means implemented and source-verified only when checks are recorded below. +APK identity, real Android behavior and human acceptance are separate evidence. + +| ID | PU850 source/pattern | Flutter destination/adaptation | Batch | Status / acceptance | +|---|---|---|---|---| +| U01 | MainActivity arabicToDecimal; address edit filter | Shared localized digit/separator normalization, caret-safe formatter, inline errors | 1 | Source implemented; invalid/partial input, Persian/Arabic paste, selection and IME checks passed | +| U02 | MainActivity address/port validation | IPv4, DNS, IPv6, HTTP(S)/WS(S), explicit ports 1–65535, canonical origin preview and known service URL admission | 1 | Source implemented; explicit port/TLS, invalid paths and credential guards passed | +| U03 | checkAddressServer TCP + /pu proof | Bounded same-host candidate ports and read-only PCController/Pealayer identity verification; optional saved offline address | 1 | Source implemented; bounded fixture identity/redirect/body checks passed; live LAN acceptance open | +| U04 | getDialogTitle, rounded_dialog, BottomSheetStyle, IME listeners | Shared responsive modal shell, 44px Close, icon/title/footer, keyboard-safe scrolling, inline connection result/cancel | 1 | Source implemented for connection and root confirmations; phone/tablet/light/dark/keyboard/RTL source checks passed; other surfaces U10 | +| U05 | native NetworkCallback, retry intent, SocketClient lost timeout | Immediate selected-peer network recovery, coalesced opens, WebSocket keepalive, explicit disconnect and owner fencing | 1 | Source implemented; loopback keepalive, delayed/canceled opens, duplicate wakeups, network recovery and explicit-disconnect checks passed | +| U06 | state-driven connection icon/action and selected address | Preserve Connect/Cancel lifecycle and Disconnect More; show actual candidate/product and saved target | 1 | Latest request: single Connect/Cancel/Disconnect lifecycle toggle; verified target, no inferred board readiness | +| U07 | download byte totals, throttled progress, cancel confirmation, notifications | Existing bounded APK/firmware transfers, actual progress receipts and optional Android transfer notifications | 2 | Planned; preserve integrity, installation consent and uncertain outcomes | +| U08 | MediaStore export and afterImageSavedDialog Open/Share | Contextual saved screenshot/document actions via existing SAF/capture path | 2 | Planned; only offer actions after a confirmed local file/URI | +| U09 | applyViewDirection/getTypeface/locale resources | English/Persian/Arabic UI resources, LTR endpoints/measurements inside RTL, localized numbers | 2 | Partial: endpoint direction/digits and original Droid Sans Mono ported; complete localization remains open | +| U10 | contextual question/receipt/login dialogs | Migrate remaining channel/cue/effect/settings dialogs to shared shell and consistent validation | 2 | Partial: channel editor migrated with kind-scoped folder dropdown; cue/effect/settings bodies remain open | +| U11 | semantic errors/icons and disabled action state | Actual availability, field errors, outcome-unknown receipts and accessible focus/keyboard behavior | 1–2 | Partial: field/cancel/edit-during-probe and ownership guards passed; extend each remaining modal | +| U12 | click haptics and user-visible long-task state | Optional native haptics for explicit user actions and real operation notifications | 3 | Planned; no constant vibration, filler or repaint loops | +| U13 | fixed status/measurement panels and subdued palette | Retain existing Pastel/Vibrant configuration; consistent dialog surfaces and centered text | 1 | Source implemented for shared dialog theme; Pastel/Vibrant palettes preserved and rendered | +| U14 | wake-lock/screen-on methods and portrait layout | User-controlled monitoring screen policy and adaptive tablet layout | 3 | Evaluate; never force wake/unlock, portrait or media interruption by default | +| X01 | scale, tare, receipt/report, access-level and printer-font protocol | Excluded business/device logic | — | Do not import into PCController/Pealayer | +| X02 | ringtone/notification beep handler | Excluded audio approximation | — | Keep bounded frame-counted PCM buzzer mirror | +| X03 | inactive WebServer/StorageUtils, generic socket demo | Excluded direct implementation; modern API concepts only if needed | — | Keep protected local RPC, SAF and current permission/owner contracts | +| X04 | AsyncTask, Activity-held helpers, arbitrary redirects, task-clearing locale restart, unbounded bulk reads | Excluded legacy lifecycle/transport mechanics | — | Use bounded asynchronous ownership-aware Flutter/native adapters | + +## Batch 1 verification + +The first batch targets 0.1.10+11. Twenty-five focused checks passed: +six address/detection checks, seven dialog/input/cancel/race/geometry checks, +four peer-recovery checks, six existing API credential/uncertain-write guards +and both phone/tablet front-panel loading/scroll regressions. The analyzer is +clean. One additional opt-in renderer passed for actual application connection +modals in phone Vibrant, tablet dark Pastel and narrow keyboard/error layouts. +Those three PNGs are controlled source fixtures, not actual Android screenshots +or proof of endpoint/network behavior. + +Two concurrent detection workers inspect at most six same-host candidates, +using bounded read-only health/product responses without redirects or tokens. +Explicit ports are honored; explicit HTTPS never downgrades. A failed probe +can save a validated offline target through an explicit Connect when available +action; wrong-product results cannot take that shortcut. Native network changes +wake the selected enabled session, coalescing opens and preserving explicit +disconnect, request/response-only relay routing and command non-replay. + +Read-only two-second LAN checks to the previously recorded Cafe addresses +192.168.100.155:8787 and :8080 failed to connect on 2026-10-13. Live Android +endpoint detection and reconnect behavior remain unverified. No PU850 business +operation or physical PCController action was sent. Device/package receipts +are separate from these source checks and are appended after delivery. + +The original PU850 res/font/monospace.ttf is ported unchanged as +assets/fonts/DroidSansMono.ttf, SHA-256 +`db19a1fdaba41cc4a2fec0330e5c15e71c6dd68a3ef074f4f28268828b45c862`. +Its embedded metadata identifies Droid Sans Mono Regular 1.00, Google +Corporation copyright 2007 and Apache License 2.0. The full license and source +notice are bundled and registered with Flutter LicenseRegistry, following the +[Apache redistribution conditions](https://www.apache.org/licenses/LICENSE-2.0.txt). +No commercial/localized PU850 fonts or business assets are imported. + +Connection endpoints have origin semantics. Firmware manifest and media URLs +retain their own path/scheme contracts; do not apply an origin-stripping parser +to those fields. Their contextual field/dialog improvements belong to U10. + +Next batches are U07/U08 transfer and saved-file actions, U09 localization, +U10 remaining channel/cue/effect/settings dialog bodies and U11 their validation. +U12 haptics and U14 explicit screen policy require product decisions and actual +Android checks. They remain open; first-batch delivery is not full UI acceptance. + +## Batch 1 package/device receipt + +One ARM64 release build from c561c207edf75bfc347b6bcda24ee1249e50cb9c passed. +APK 0.1.10/code 11 is 20,133,328 bytes, SHA-256 +`3e40eda6c0d36589ebe04081d46dc578464555eee40592662aab2fbb97e4593d`. +Its inherited Android development signature verified. One ordinary non-streamed, +in-place tablet install succeeded under the existing saved-drafts/update and +continuing delivery authorization. Installed version/code/base APK hash match. +The tablet's active video foreground was preserved; no controller launch or +physical output/audio/firmware action was sent. Live interaction is still open. + +The deduplicated SM-S908E phone remains 0.1.2/code 3, with 499,840 KiB available +below its previously verified 1 GiB installer threshold. No repeated install, +data removal, reserve bypass or unlock was attempted. The continuous acceptance +goal remains active. android-0.1.10-delivery.json records exact identities and +source/device/physical acceptance separately. + +## Batch 2 - remaining authoring/dialog input surfaces + +U10 cue/effect/custom-color dialogs now share ControllerDialog with keyboard-safe scrolling, explicit Close/Cancel and fixed cue busy geometry. U11 effect integer/repetition/display bounds follow the native Go config validator, retain invalid edits and reject unapplied/invalid technical JSON without replacing the draft. Media authority normalization preserves resource path/query and native host-path semantics; firmware imports currently use the document picker. Shared source checks and five current phone/tablet/theme fixtures are recorded in UI-REQUIREMENT-LEDGER.md. Android IME, live resources, haptics/localization/screen policy and full physical/human acceptance remain open. diff --git a/mobile/PU850-source-inventory.json b/mobile/PU850-source-inventory.json new file mode 100644 index 000000000..181f2723d --- /dev/null +++ b/mobile/PU850-source-inventory.json @@ -0,0 +1,382 @@ +{ + "source_root": "C:\\Users\\David\\Desktop\\PU850", + "reviewed_on": "2026-10-13", + "source_modified": false, + "files": [ + { + "path": "app/build.gradle", + "lines": 81, + "sha256": "9e922d0bc22eae0523e6b898b9168dd3bb5beedc18d0756cab585e583d6fe1d2" + }, + { + "path": "app/src/main/AndroidManifest.xml", + "lines": 50, + "sha256": "49ef5e2b41590fe8ea05acaeb238d400943bd65cad776615b6bda70fd6033d95" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/CommonDefine.java", + "lines": 405, + "sha256": "027dc663c0d093230a2482a34704cc7ad85feb80d60d8717a1201e25901b9a9e" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/MainActivity.java", + "lines": 5429, + "sha256": "de5c45e329c15fe9083c3ffa217192a71c4906806970822ee72fecd050f1e951" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/utils/FileDownloader.java", + "lines": 291, + "sha256": "710c32292f8dda8f51d4915eaa131dd798ba22d7c230c933fcc1941ed43ad386" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/utils/HttpRequest.java", + "lines": 301, + "sha256": "d7e1b9d109026e3c6800c9c0a7c16578df05575c4f5ac5fe2cbe8a75f6d7219c" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/utils/SocketClient.java", + "lines": 114, + "sha256": "146a819e8577199ba9280034d3091f4c4a0888555cfc314ffa395111ab14558c" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/utils/SocketServer.java", + "lines": 55, + "sha256": "d346152ad0dfe025ee96f55adb0cf8c5c081df795ae6e89259fcfbab4d7ef924" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/utils/StorageUtils.java.disabled", + "lines": 839, + "sha256": "c332936e83e9e7d8ab9fc7101fe6142e7d9039b5d6c323a026cf4266cc873707" + }, + { + "path": "app/src/main/java/com/pandcaspian/indicator/utils/WebServer.java.disabled", + "lines": 475, + "sha256": "fecc7cf9b108502b092d0eeaacc1b0cd74c4c55c1ac25b3d50ec67a1c9c633c0" + }, + { + "path": "app/src/main/res/anim/progress_indeterminate_horizontal_rect1.xml", + "lines": 15, + "sha256": "f337a4fbdb59ab0f08b98a62927ddef4fc558bfb1a8a83357a4b1c3b12bd3fb5" + }, + { + "path": "app/src/main/res/anim/progress_indeterminate_horizontal_rect2.xml", + "lines": 15, + "sha256": "531eecd0c6be941f52a11f844efbe775285118aca59d462d4ed4e18a5cb3eafb" + }, + { + "path": "app/src/main/res/drawable/ic_icon_account.xml", + "lines": 9, + "sha256": "39fce3aff2e7ace8b2e54bc1e185f47211370e783f6068b8b665b78f5b5e3e69" + }, + { + "path": "app/src/main/res/drawable/ic_icon_caution.xml", + "lines": 12, + "sha256": "7b30a2a0b35030200acc54f88a4fff0ee39cfe2279d9b6ed0884dda437a743f9" + }, + { + "path": "app/src/main/res/drawable/ic_icon_clock.xml", + "lines": 5, + "sha256": "30e1d7f5f100a54b8a4694f424f78131dd1ca608842fb3bad12b4604d7a4ecb7" + }, + { + "path": "app/src/main/res/drawable/ic_icon_delete.xml", + "lines": 28, + "sha256": "97b7b1b657c83179274a9c1897178aca546624a2cc4fc68126ee023411b827ef" + }, + { + "path": "app/src/main/res/drawable/ic_icon_disconnected.xml", + "lines": 12, + "sha256": "9d74d3feef5a3150d79873920bf3ec958b9104c56c219328b0d062572bcd4652" + }, + { + "path": "app/src/main/res/drawable/ic_icon_error.xml", + "lines": 16, + "sha256": "b920ab1130c2dba8dc2dcd0265358fd8dd547e89e38f9536fae2dafc15793f31" + }, + { + "path": "app/src/main/res/drawable/ic_icon_exit.xml", + "lines": 4, + "sha256": "6fc02be16dff7ceb8ae86460765b3179aeee405196031a2d160e8b2514b39b28" + }, + { + "path": "app/src/main/res/drawable/ic_icon_information.xml", + "lines": 14, + "sha256": "3ec413fd113050757b0e3fd085b2c37b6d33743c529cf02d518dda765685b7e6" + }, + { + "path": "app/src/main/res/drawable/ic_icon_question.xml", + "lines": 16, + "sha256": "5bb3cb4fb42d874661dea4cc3f717a9b06b422f70659cbd5a97b2fc052d9a8be" + }, + { + "path": "app/src/main/res/drawable/ic_icon_save.xml", + "lines": 28, + "sha256": "f0988708426e10f712614e430c4b75979b5d9077422bec79941e553e1d0c6fb6" + }, + { + "path": "app/src/main/res/drawable/ic_icon_settings.xml", + "lines": 8, + "sha256": "c98a401a28007e9a57e6c7d4ecee84fbe5e562521b108eb9acd56a913c6fa160" + }, + { + "path": "app/src/main/res/drawable/ic_icon_user.xml", + "lines": 12, + "sha256": "1ea802239172de4c70832aafdd9461200540b66d816f98bb30bfc6b95837cd5b" + }, + { + "path": "app/src/main/res/drawable/ic_icon_wait.xml", + "lines": 44, + "sha256": "e73469673cde9a32db037c08da39908c1d600c4e555a6445ec269509790d0a85" + }, + { + "path": "app/src/main/res/drawable/ic_icon_warning.xml", + "lines": 5, + "sha256": "cc1415901e2909837afd72abc767bfdd080fbd993dc4cdd27befdba7286595a0" + }, + { + "path": "app/src/main/res/drawable/ic_icon_wifi.xml", + "lines": 16, + "sha256": "3bfca282dae8696859d858863839da71e8ad2a24151e7c8a023907f8bf03532e" + }, + { + "path": "app/src/main/res/drawable/ic_key_develop.xml", + "lines": 13, + "sha256": "319f3247d3eb62ccb61d7bda974c9560b322f19b9021db2ecc27f9cab4e26998" + }, + { + "path": "app/src/main/res/drawable/ic_key_hide.xml", + "lines": 12, + "sha256": "4664fcaf4cfd0f15eacb215d7fae14771c2ebeefb2f82f53cf73049874615774" + }, + { + "path": "app/src/main/res/drawable/ic_key_net.xml", + "lines": 15, + "sha256": "330927bc02ca266a860787e6e85b9226d171bf21b449bf5afef12d635b500e1b" + }, + { + "path": "app/src/main/res/drawable/ic_key_power.xml", + "lines": 12, + "sha256": "7d651cfb375c5907858dedbaef25c242118a871f5ade02254e900134c308c334" + }, + { + "path": "app/src/main/res/drawable/ic_key_receipt.xml", + "lines": 12, + "sha256": "29bc134a5b7439941f6564dbf1aabdcd3bf34bfb07a8dd0333d97e5cb98a0b10" + }, + { + "path": "app/src/main/res/drawable/ic_key_report.xml", + "lines": 12, + "sha256": "74a2d61dcad555aa702e1aaaeef81033184ebee112ffd5872c7281fa4a977a6a" + }, + { + "path": "app/src/main/res/drawable/ic_key_settings.xml", + "lines": 12, + "sha256": "e918f9a8151e8ebb83b558982d261d168311ba667cd1f85bd0b6c2f04e8fd1a3" + }, + { + "path": "app/src/main/res/drawable/ic_key_show.xml", + "lines": 24, + "sha256": "74270c003225646b5ad1a14bee870a3b29eea981b3a0877f3fa3c7f55163c1ac" + }, + { + "path": "app/src/main/res/drawable/ic_key_tare.xml", + "lines": 12, + "sha256": "9a0727890f3d82f6b9ff29f22fb58f718f2a561358090a06db678460954baeea" + }, + { + "path": "app/src/main/res/drawable/ic_launcher_background.xml", + "lines": 10, + "sha256": "1075af9007d0068608e3fd0de7ff612e0b7436932ae9654c05b6a167c847f22d" + }, + { + "path": "app/src/main/res/drawable/ic_logo_pand.xml", + "lines": 12, + "sha256": "74b83a716449f16efcb28b33ee1b444cfd3cee6915dc0d30303b67b4e08ae94a" + }, + { + "path": "app/src/main/res/drawable/ic_pandapp_foreground.xml", + "lines": 19, + "sha256": "25509ac2d9ad61330e44a954bd341327c805153684d4a95714149f90bb4b8bf7" + }, + { + "path": "app/src/main/res/drawable/ic_visibility_off.xml", + "lines": 3, + "sha256": "a1bb8f792ea6616eae412754575a751d46972c98fa719a0e3bed17d51709e52f" + }, + { + "path": "app/src/main/res/drawable/ic_visibility_on.xml", + "lines": 3, + "sha256": "ab7df82020200d8df5b7bac0793d45771af1f2b28d7cbd6a0564f892ca6172dd" + }, + { + "path": "app/src/main/res/drawable/progress_horizontal.xml", + "lines": 23, + "sha256": "dc642cd846babd30b439f07ba8e9baf83ccee103b355c0a2a7ba42ef514b927e" + }, + { + "path": "app/src/main/res/drawable/progress_horizontal_indeterminate.xml", + "lines": 10, + "sha256": "f6349dbbcd8979f014bb12427213b0643280ee45b775a3eea3fc359c9e3ab7d3" + }, + { + "path": "app/src/main/res/drawable/rounded_dialog.xml", + "lines": 22, + "sha256": "8837032caced8390c21692a4ebc6a3170839b10eadd3c5022d714d25e96a8f33" + }, + { + "path": "app/src/main/res/drawable/rounded_dialog_bottom.xml", + "lines": 8, + "sha256": "9c8b8979ea6210aa2cba1ee99798da33e312d11501556c381d8ee93c560ff4ec" + }, + { + "path": "app/src/main/res/drawable/vector_drawable_progress_indeterminate_horizontal.xml", + "lines": 37, + "sha256": "ab5b7dd669e784afe3e792cb3e66c87b15bc3a4e789b3d6455e8a233ff95ffe6" + }, + { + "path": "app/src/main/res/drawable-night/ic_icon_clock.xml", + "lines": 5, + "sha256": "be84de971b1ca95f291b6173a477d6d580e11c6627d4b647c381ea7ccb289402" + }, + { + "path": "app/src/main/res/drawable-night/ic_icon_information.xml", + "lines": 14, + "sha256": "fabca53943fad9ca53c69cd6891187ff329fe16b3f2154a8838ad9f56872e646" + }, + { + "path": "app/src/main/res/drawable-night/ic_icon_settings.xml", + "lines": 9, + "sha256": "9ea5a01c7b02664374d2876405eb0a07edbe9288c58058e306640b451411fd15" + }, + { + "path": "app/src/main/res/drawable-night/ic_launcher_background.xml", + "lines": 10, + "sha256": "ba5ea4d77740603868a2eb2694d2750d1d303b5ae21b5158892e84ccbe7e3561" + }, + { + "path": "app/src/main/res/drawable-night/ic_pandapp_foreground.xml", + "lines": 19, + "sha256": "62d529daf63196ec83fadc372a8bf8f7ea68ee2e201ba138f6ba39d74f6edc1b" + }, + { + "path": "app/src/main/res/interpolator/progress_indeterminate_horizontal_rect1_scalex.xml", + "lines": 4, + "sha256": "703eacbc7f3305894177ebcbb128f313304c8c86ab48832ee71aa2a72de2a405" + }, + { + "path": "app/src/main/res/interpolator/progress_indeterminate_horizontal_rect1_translatex.xml", + "lines": 4, + "sha256": "58fda28ff736755e7960ba856ab6b2435ee5e868b17e3e1d46ff80245188f326" + }, + { + "path": "app/src/main/res/interpolator/progress_indeterminate_horizontal_rect2_scalex.xml", + "lines": 4, + "sha256": "2ecc73c382150675b515c9b74b666dc2167b60cf5aea5513c15a379b077db1ff" + }, + { + "path": "app/src/main/res/interpolator/progress_indeterminate_horizontal_rect2_translatex.xml", + "lines": 4, + "sha256": "7e551e88e7bc559ba06898ac202e9441252260debbd91c8688335af00569a3a1" + }, + { + "path": "app/src/main/res/layout/activity_main.xml", + "lines": 371, + "sha256": "664b1b5184f78ee498c16015d46e8190e940f3ef5b6bb26d2f6dac8df2567b82" + }, + { + "path": "app/src/main/res/layout/toast_layout.xml", + "lines": 63, + "sha256": "1dc74d2b1f6eafcd263a8bb1132ea62134bdb1290709c1fd87cab85ce08fa5ec" + }, + { + "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml", + "lines": 5, + "sha256": "16943b5c84630a9a0c5f79ed5909d03c64729af8b8412705e65cc21342a0c0e4" + }, + { + "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml", + "lines": 5, + "sha256": "16943b5c84630a9a0c5f79ed5909d03c64729af8b8412705e65cc21342a0c0e4" + }, + { + "path": "app/src/main/res/values/arrays.xml", + "lines": 14, + "sha256": "bc9be88eb3d86181434cd7cd66ca2b9a75acad5061debeff5cb700825af9830a" + }, + { + "path": "app/src/main/res/values/attrs.xml", + "lines": 3, + "sha256": "22f303091b555d902a2bde8b19d17ad87aa07b52a984a08e4d72214b0a25e54d" + }, + { + "path": "app/src/main/res/values/colors.xml", + "lines": 28, + "sha256": "0687ae87b076c112d6034237cba6006e0722eebbc38ce552d3ba7a12c71f879f" + }, + { + "path": "app/src/main/res/values/dimens.xml", + "lines": 5, + "sha256": "60d65d852bdce84687e0aeb99658ed637fa3d36f87fcf917ae7f369a440007cd" + }, + { + "path": "app/src/main/res/values/strings.xml", + "lines": 116, + "sha256": "0575cc351dda1f2ad4fa1b20abfffad29599a906ec40aad55aec24a01e1f9347" + }, + { + "path": "app/src/main/res/values/styles.xml", + "lines": 138, + "sha256": "5039aa412fdef77c7f1d0f9ac3fd8643d0601736229a8989c6fa33ee3be5e612" + }, + { + "path": "app/src/main/res/values-en/arrays.xml", + "lines": 14, + "sha256": "bc9be88eb3d86181434cd7cd66ca2b9a75acad5061debeff5cb700825af9830a" + }, + { + "path": "app/src/main/res/values-en/strings.xml", + "lines": 106, + "sha256": "81fccc1c3daf29f8184578255a603e2fc51c465d6379928c86d90306f4a5907f" + }, + { + "path": "app/src/main/res/values-fa/arrays.xml", + "lines": 14, + "sha256": "930e7255da3b39cef91d429d9c24ffdc20f4066744a9edb09de94092b21b0655" + }, + { + "path": "app/src/main/res/values-fa/strings.xml", + "lines": 104, + "sha256": "7c4c35fd674deae3707c414100188f5af0c142fcfee4d45e2e7cfbf8eb749b49" + }, + { + "path": "app/src/main/res/values-night/colors.xml", + "lines": 27, + "sha256": "96d99405dd69d23ec32e9ac05c37c85fe3d67e526cb067e4619de21af4d6414f" + }, + { + "path": "app/src/main/res/values-night/styles.xml", + "lines": 138, + "sha256": "77cd9f3e1e19b5def1d2475ccc5d2ec60e7dbb29e7833a5534b57140a1f925eb" + }, + { + "path": "app/src/main/res/values-night-v27/styles.xml", + "lines": 140, + "sha256": "b7a2f353b7057e9b7a1cbc1730f3ab35968d6101ff6e29914e0137edd8a0e18f" + }, + { + "path": "app/src/main/res/values-v27/styles.xml", + "lines": 140, + "sha256": "334490cbfc8a2f3ab79748487cebf1744d35e5a00a83b0c4cf97c2edb80969a7" + }, + { + "path": "build.gradle", + "lines": 28, + "sha256": "50ab7a721a345c8326c0144790679ef010235cc8794a14cb499590fca9142f31" + }, + { + "path": "settings.gradle", + "lines": 2, + "sha256": "fd44b64c4008784bb180b8c44e1b307abd5c63a52b5073c0db4a6ece58eb4c3f" + } + ] +} \ No newline at end of file diff --git a/mobile/README.md b/mobile/README.md index 4c24cd189..fc547d245 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 @@ -15,16 +15,52 @@ 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 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. +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. +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 @@ -82,6 +118,62 @@ installation/launch of this separate app, not destructive physical board tests. ## Provenance and remaining acceptance +### Unified workspace, discovery and self-package RPC + +PWM sliders preserve the editing/pending setpoint through command completion. +The typed setter returns the host's post-acknowledgement board readback; reads +issued before that write cannot replace it. The return value is the authoritative +logical percentage after configured curve/quantization, not optimistic input. +Changed owner, unavailable readback or lost acknowledgement stays uncertain and +is never replayed. Actual Cafe read-only checks reported PWM available and no +PWM/framing/CRC errors; no physical output was changed for this verification. +Preferences support compact one-line relay cards (default) or explicit two-line +On/Off controls, with the same observed state, lock and safety rules. + +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/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/UI-REQUIREMENT-LEDGER.md b/mobile/UI-REQUIREMENT-LEDGER.md new file mode 100644 index 000000000..47115dd4d --- /dev/null +++ b/mobile/UI-REQUIREMENT-LEDGER.md @@ -0,0 +1,25 @@ +# Flutter UI requirement ledger - 0.1.14 + +The user design rejection remains authoritative. This ledger separates implemented UI, source verification, Android delivery and live/human acceptance; it does not close the broader acceptance goal. + +| Requirement | UI status and proof | Remaining acceptance | +| --- | --- | --- | +| One professional activity workspace | Unified Live/Channels/Panel/Studio/Devices/Firmware/Events; rounded selection-aware navigation and a scrolling tablet rail. Current activity is brought into view on navigation/rail creation. | Human phone/tablet composition acceptance | +| Contextual connection action and pill | One lifecycle icon action per peer. Narrow compact views use full-width peer rows with end-aligned actual state pills; uncompact full cards remain. | Live reconnection and human acceptance | +| Channel indicators, menus and sophisticated controls | Current observed relay/PWM/motion pills, guarded indicator toggles, equal-width semantic actions, three-dot management, inline rename/handle reorder and native presentation revisions retained. Existing output checks pass. | Actual hardware feedback and mixed-client acceptance | +| Previous/current themes, header and button customization | Pastel/Vibrant and all five palettes retained; appearance preview, independent headers and actual action styles, compact/roomy layouts and branding/one-or-both launcher controls. | Actual Android task/launcher switching and human acceptance | +| Individual chart rows in uncompact mode | Existing per-series full-width charts/Connected pill checks remain passing. Compact metric grid retained. | Real sensor charts and human acceptance | +| No jump on front-panel loading/pull refresh | Global progress has fixed geometry; existing phone/tablet viewport and pull-refresh checks pass. Cue progress also reserves its slot and outstanding save cannot be closed. | Physical on-device interaction | +| Folder selection is a dropdown | Channel assignment already uses a kind-scoped native catalog. Effect editor now uses existing native/local category choices, retains the current raw name and offers explicit new-folder creation. | Human authoring acceptance | +| Professional dialogs and inline validation | Channel/connection/cue/effect/custom palette use ControllerDialog; bounded scrolling, explicit Close/Cancel and footer actions. Invalid effect numeric input remains through selection changes, blocks saving, and cannot silently use old numbers. Pending saves disable all editable fields and failed saves retain edits. | Android keyboard/IME and human acceptance | +| No discarded technical edits/duplicate drafts | Unapplied JSON disables typed step editing and must be applied/reverted before saving. Typed JSON shape/common native ranges are checked. Unchanged Save sends no callback; saved local IDs replace the matching draft without accumulation. | Full native descriptor/physical playback remains receiver-owned | +| URL normalization/validation | Existing endpoint normalization/detection retained. Media URLs now validate authority/ports/credentials/control characters inline; localized authority digits normalize, resource paths/query/Unicode content and native Windows/UNC paths retain their semantics. Firmware imports use the document picker rather than a URL input. | Live media resources and all transport acceptance | +| Contextual cards/no narration | Empty discovered/live effect/operation cards and duplicate new-effect action omitted; firmware architecture narration removed. Useful offline draft creation/editing remains. Idle playback cancellation stays absent. | Continued live/human review | +| Seven-segment, beep, mode, firmware and Go tools | Existing live segment/buttons, native buzzer and Android PCM mirror, mode, artifact/backup workflow and dedicated Go tools preserved. | Physical firmware/segment/audio fidelity; all Go/egui authoring/platform parity | +| Bridging, automation, discovery and peer updates | Existing typed routes, protected Android automation, advertised transports, saved auto-connect intent, DNS-SD/SSDP/MNDP and bounded verified peer packages preserved. | Real Android multicast/API transports, two-device install consent and standalone USB/native core | + +Verification: 44 focused functional checks plus one opt-in renderer (five actual Flutter phone/tablet/theme routes) passed. Analyzer clean. Source fixtures are not Android screenshots or physical/human acceptance. No board output, beep, playback or firmware action was issued. Native WS ownership changes are preserved on the prior topic; another chat owns native Pealayer and later Go host startup work. + +Human, physical/audio and platform gates are explicitly retained; no screenshot or APK installation closes them. See ACCEPTANCE.md, GO-FLUTTER-PARITY.md and PU850-IMPORT-LEDGER.md for detailed existing evidence. Android source candidate is 0.1.14+15; delivery identity is appended only after build/install verification. + +Delivery observed 2026-10-13 08:14 UTC: final 0.1.14/code15 ARM64 release from 55c747dda405eb16d155c0a32be8c0aaee556a96; 20,266,808 bytes, SHA256 7256efa4d6f143dde1104fc2ddc3871fd76dc7db6adf06e42d5555a2af9879e7; inherited development signer verified. Earlier pre-guard build was superseded before delivery. One ordinary non-streamed in-place tablet install succeeded; SM-X906B R52T60C6V9B installed package/version/base hash verified, locked video activity/task retained. SM-S908E R5CT44LZ42K remains 0.1.2/code3 with 402,076 KiB available below its 1 GiB vendor installation threshold; Android LOW state persists, no repeated identical install, data deletion or reserve bypass. Both reachable distinct serials inventoried; duplicate phone mDNS advertisements deduplicated. No controller launch over the tablet video session and no physical output/audio/firmware/player action. Physical, audio, Android IME/launcher/discovery/transport and human acceptance remain open. PR #661 contains this UI topic stacked on #660. diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index be3943c96..09e3a4178 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -1,5 +1,6 @@ gradle-wrapper.jar /.gradle +/.kotlin/ /captures/ /gradlew /gradlew.bat diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 3f3b250eb..fc6206c6a 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -47,3 +47,7 @@ kotlin { flutter { source = "../.." } + +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 6c91b8497..21c7f807a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - - - - + + + + + + + + + +