From 0fb44446b6066bc6365c100b8766581eab5ff3d0 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Fri, 30 Oct 2026 18:00:44 +0330 Subject: [PATCH 1/5] Own opcode namespaces and reject cross-protocol dispatch --- Project/ProtocolContract.json | 80 ++++- Project/UartProtocol.cpp | 8 +- Tools/Build/generate-protocol-contract.mjs | 282 ++------------- .../protocol-contract-generator.test.mjs | 36 ++ Tools/Build/sync-controller-opcodes.mjs | 38 ++ .../docs/Protocol-and-Network-API.md | 10 +- Tools/Controller/internal/native/frame.go | 4 + .../Controller/internal/native/frame_test.go | 18 +- Tools/Controller/internal/native/opcodes.go | 39 -- .../native/protocol_contract_generated.go | 72 ++++ .../VirtualBoard/tests/uart_protocol_test.cpp | 22 +- docs/Controller-Convergence.md | 11 +- docs/Front-Panel-and-Menus.md | 2 +- interfaces/controller-wire/README.md | 31 ++ interfaces/controller-wire/consumer.mjs | 49 +++ interfaces/controller-wire/published.json | 164 +++++++++ interfaces/controller-wire/registry.mjs | 332 ++++++++++++++++++ 17 files changed, 872 insertions(+), 326 deletions(-) create mode 100644 Tools/Build/sync-controller-opcodes.mjs create mode 100644 interfaces/controller-wire/README.md create mode 100644 interfaces/controller-wire/consumer.mjs create mode 100644 interfaces/controller-wire/published.json create mode 100644 interfaces/controller-wire/registry.mjs diff --git a/Project/ProtocolContract.json b/Project/ProtocolContract.json index 2b403a47c..ba952930a 100644 --- a/Project/ProtocolContract.json +++ b/Project/ProtocolContract.json @@ -94,5 +94,83 @@ { "cpp": "HardwareUnavailable", "go": "ErrorHardwareUnavailable", "value": 4 }, { "cpp": "Busy", "go": "ErrorBusy", "value": 5 }, { "cpp": "Unsafe", "go": "ErrorUnsafe", "value": 6 } - ] + ], + "pakar": { + "envelope": {"magic":165,"reserved":0,"maximumPayload":48,"rawFrameOverhead":6,"maximumEncodedFrameOverhead":2}, + "opcodes": [ + {"cpp":"kHello","rust":"HELLO","dart":"hello","value":1,"wireName":"HELLO","go":"PakarHello"}, + {"cpp":"kGetStatus","rust":"GET_STATUS","dart":"getStatus","value":2,"wireName":"GET_STATUS","go":"PakarGetStatus"}, + {"cpp":"kSetStreamPeriod","rust":"SET_STREAM_PERIOD","dart":"setStreamPeriod","value":3,"wireName":"SET_STREAM_PERIOD","go":"PakarSetStreamPeriod"}, + {"cpp":"kBuzzer","rust":"BUZZER","dart":"buzzer","value":16,"wireName":"BUZZER","go":"PakarBuzzer"}, + {"cpp":"kAudioCue","rust":"AUDIO_CUE","dart":"audioCue","value":17,"wireName":"AUDIO_CUE","go":"PakarAudioCue"}, + {"cpp":"kAudioStream","rust":"AUDIO_STREAM","dart":"audioStream","value":18,"wireName":"AUDIO_STREAM","go":"PakarAudioStream"}, + {"cpp":"kDisplayText","rust":"DISPLAY_TEXT","dart":"displayText","value":56,"wireName":"DISPLAY_TEXT","go":"PakarDisplayText"}, + {"cpp":"kSetMode","rust":"SET_MODE","dart":"setMode","value":75,"wireName":"SET_MODE","go":"PakarSetMode"}, + {"cpp":"kCellAction","rust":"CELL_ACTION","dart":"cellAction","value":76,"wireName":"CELL_ACTION","go":"PakarCellAction"}, + {"cpp":"kGameReset","rust":"GAME_RESET","dart":"gameReset","value":77,"wireName":"GAME_RESET","go":"PakarGameReset"}, + {"cpp":"kOutputMask","rust":"OUTPUT_MASK","dart":"outputMask","value":78,"wireName":"OUTPUT_MASK","go":"PakarOutputMask"}, + {"cpp":"kPing","rust":"PING","dart":"ping","value":79,"wireName":"PING","go":"PakarPing"}, + {"cpp":"kProgrammingState","rust":"PROGRAMMING_STATE","dart":"programmingState","value":80,"wireName":"PROGRAMMING_STATE","go":"PakarProgrammingState"}, + {"cpp":"kSetBrightness","rust":"SET_BRIGHTNESS","dart":"setBrightness","value":81,"wireName":"SET_BRIGHTNESS","go":"PakarSetBrightness"}, + {"cpp":"kSetLedPattern","rust":"SET_LED_PATTERN","dart":"setLedPattern","value":82,"wireName":"SET_LED_PATTERN","go":"PakarSetLedPattern"}, + {"cpp":"kNetworkConfig","rust":"NETWORK_CONFIG","dart":"networkConfig","value":83,"wireName":"NETWORK_CONFIG","go":"PakarNetworkConfig"}, + {"cpp":"kAccessConfig","rust":"ACCESS_CONFIG","dart":"accessConfig","value":84,"wireName":"ACCESS_CONFIG","go":"PakarAccessConfig"}, + {"cpp":"kDeviceName","rust":"DEVICE_NAME","dart":"deviceName","value":85,"wireName":"DEVICE_NAME","go":"PakarDeviceName"}, + {"cpp":"kPcContract","rust":"PC_CONTRACT","dart":"pcContract","value":86,"wireName":"PC_CONTRACT","go":"PakarPcContract"}, + {"cpp":"kPcContractChunk","rust":"PC_CONTRACT_CHUNK","dart":"pcContractChunk","value":87,"wireName":"PC_CONTRACT_CHUNK","go":"PakarPcContractChunk"}, + {"cpp":"kGetTelemetry","rust":"GET_TELEMETRY","dart":"getTelemetry","value":88,"wireName":"GET_TELEMETRY","go":"PakarGetTelemetry"}, + {"cpp":"kGetIdentity","rust":"GET_IDENTITY","dart":"getIdentity","value":89,"wireName":"GET_IDENTITY","go":"PakarGetIdentity"}, + {"cpp":"kEspPanel","rust":"ESP_PANEL","dart":"espPanel","value":90,"wireName":"ESP_PANEL","go":"PakarEspPanel"}, + {"cpp":"kAvrRxDiagnostic","rust":"AVR_RX_DIAGNOSTIC","dart":"avrRxDiagnostic","value":91,"wireName":"AVR_RX_DIAGNOSTIC","go":"PakarAvrRxDiagnostic"}, + {"cpp":"kSetLedOutput","rust":"SET_LED_OUTPUT","dart":"setLedOutput","value":92,"wireName":"SET_LED_OUTPUT","go":"PakarSetLedOutput"}, + {"cpp":"kGetTableSnapshot","rust":"GET_TABLE_SNAPSHOT","dart":"getTableSnapshot","value":93,"wireName":"GET_TABLE_SNAPSHOT","go":"PakarGetTableSnapshot"}, + {"cpp":"kTableCommand","rust":"TABLE_COMMAND","dart":"tableCommand","value":94,"wireName":"TABLE_COMMAND","go":"PakarTableCommand"}, + {"cpp":"kCellsAction","rust":"CELLS_ACTION","dart":"cellsAction","value":95,"wireName":"CELLS_ACTION","go":"PakarCellsAction"}, + {"cpp":"kShellRequest","rust":"SHELL_REQUEST","dart":"shellRequest","value":96,"wireName":"SHELL_REQUEST","go":"PakarShellRequest"}, + {"cpp":"kPeerReport","rust":"PEER_REPORT","dart":"peerReport","value":97,"wireName":"PEER_REPORT","go":"PakarPeerReport"}, + {"cpp":"kCellAssignments","rust":"CELL_ASSIGNMENTS","dart":"cellAssignments","value":98,"wireName":"CELL_ASSIGNMENTS","go":"PakarCellAssignments"}, + {"cpp":"kCellLock","rust":"CELL_LOCK","dart":"cellLock","value":99,"wireName":"CELL_LOCK","go":"PakarCellLock"}, + {"cpp":"kHostDemand","rust":"HOST_DEMAND","dart":"hostDemand","value":100,"wireName":"HOST_DEMAND","go":"PakarHostDemand"}, + {"cpp":"kPixelDiagnostic","rust":"PIXEL_DIAGNOSTIC","dart":"pixelDiagnostic","value":101,"wireName":"PIXEL_DIAGNOSTIC","go":"PakarPixelDiagnostic"}, + {"cpp":"kKeyMask","rust":"KEY_MASK","dart":"keyMask","value":102,"wireName":"KEY_MASK","go":"PakarKeyMask"}, + {"cpp":"kKeySettings","rust":"KEY_SETTINGS","dart":"keySettings","value":103,"wireName":"KEY_SETTINGS","go":"PakarKeySettings"}, + {"cpp":"kGamePress","rust":"GAME_PRESS","dart":"gamePress","value":104,"wireName":"GAME_PRESS","go":"PakarGamePress"}, + {"cpp":"kKeyPresentation","rust":"KEY_PRESENTATION","dart":"keyPresentation","value":105,"wireName":"KEY_PRESENTATION","go":"PakarKeyPresentation"}, + {"cpp":"kUsbApi","rust":"USB_API","dart":"usbApi","value":106,"wireName":"USB_API","go":"PakarUsbApi"}, + {"cpp":"kAck","rust":"ACK","dart":"ack","value":128,"wireName":"ACK","go":"PakarAck"}, + {"cpp":"kHelloResponse","rust":"HELLO_RESPONSE","dart":"helloResponse","value":129,"wireName":"HELLO_RESPONSE","go":"PakarHelloResponse"}, + {"cpp":"kErrorResponse","rust":"ERROR_RESPONSE","dart":"errorResponse","value":130,"wireName":"ERROR_RESPONSE","go":"PakarErrorResponse"}, + {"cpp":"kStatusResponse","rust":"STATUS_RESPONSE","dart":"statusResponse","value":144,"wireName":"STATUS_RESPONSE","go":"PakarStatusResponse"}, + {"cpp":"kGameState","rust":"GAME_STATE","dart":"gameState","value":163,"wireName":"GAME_STATE","go":"PakarGameState"}, + {"cpp":"kInputEvent","rust":"INPUT_EVENT","dart":"inputEvent","value":164,"wireName":"INPUT_EVENT","go":"PakarInputEvent"}, + {"cpp":"kCellEvent","rust":"CELL_EVENT","dart":"cellEvent","value":165,"wireName":"CELL_EVENT","go":"PakarCellEvent"}, + {"cpp":"kPong","rust":"PONG","dart":"pong","value":166,"wireName":"PONG","go":"PakarPong"}, + {"cpp":"kPcContractResponse","rust":"PC_CONTRACT_RESPONSE","dart":"pcContractResponse","value":176,"wireName":"PC_CONTRACT_RESPONSE","go":"PakarPcContractResponse"}, + {"cpp":"kTelemetryResponse","rust":"TELEMETRY_RESPONSE","dart":"telemetryResponse","value":177,"wireName":"TELEMETRY_RESPONSE","go":"PakarTelemetryResponse"}, + {"cpp":"kIdentityResponse","rust":"IDENTITY_RESPONSE","dart":"identityResponse","value":178,"wireName":"IDENTITY_RESPONSE","go":"PakarIdentityResponse"}, + {"cpp":"kEspPanelResponse","rust":"ESP_PANEL_RESPONSE","dart":"espPanelResponse","value":179,"wireName":"ESP_PANEL_RESPONSE","go":"PakarEspPanelResponse"}, + {"cpp":"kAvrRxDiagnosticResponse","rust":"AVR_RX_DIAGNOSTIC_RESPONSE","dart":"avrRxDiagnosticResponse","value":180,"wireName":"AVR_RX_DIAGNOSTIC_RESPONSE","go":"PakarAvrRxDiagnosticResponse"}, + {"cpp":"kResetTelemetryResponse","rust":"RESET_TELEMETRY_RESPONSE","dart":"resetTelemetryResponse","value":181,"wireName":"RESET_TELEMETRY_RESPONSE","go":"PakarResetTelemetryResponse"}, + {"cpp":"kTableSnapshotResponse","rust":"TABLE_SNAPSHOT_RESPONSE","dart":"tableSnapshotResponse","value":182,"wireName":"TABLE_SNAPSHOT_RESPONSE","go":"PakarTableSnapshotResponse"}, + {"cpp":"kTableCommandState","rust":"TABLE_COMMAND_STATE","dart":"tableCommandState","value":183,"wireName":"TABLE_COMMAND_STATE","go":"PakarTableCommandState"}, + {"cpp":"kPanelEvent","rust":"PANEL_EVENT","dart":"panelEvent","value":184,"wireName":"PANEL_EVENT","go":"PakarPanelEvent"}, + {"cpp":"kShellResponse","rust":"SHELL_RESPONSE","dart":"shellResponse","value":185,"wireName":"SHELL_RESPONSE","go":"PakarShellResponse"}, + {"cpp":"kHostDemandResponse","rust":"HOST_DEMAND_RESPONSE","dart":"hostDemandResponse","value":186,"wireName":"HOST_DEMAND_RESPONSE","go":"PakarHostDemandResponse"}, + {"cpp":"kAudioStreamResponse","rust":"AUDIO_STREAM_RESPONSE","dart":"audioStreamResponse","value":188,"wireName":"AUDIO_STREAM_RESPONSE","go":"PakarAudioStreamResponse"}, + {"cpp":"kUsbApiResponse","rust":"USB_API_RESPONSE","dart":"usbApiResponse","value":189,"wireName":"USB_API_RESPONSE","go":"PakarUsbApiResponse"} + ], + "reservedOpcodes": [], + "errors": [ + {"cpp":"kNoError","rust":"NO_ERROR","dart":"noError","value":0,"wireName":"NO_ERROR","go":"PakarNoError"}, + {"cpp":"kBadEnvelope","rust":"BAD_ENVELOPE","dart":"badEnvelope","value":1,"wireName":"BAD_ENVELOPE","go":"PakarBadEnvelope"}, + {"cpp":"kUnsupported","rust":"UNSUPPORTED","dart":"unsupported","value":2,"wireName":"UNSUPPORTED","go":"PakarUnsupported"}, + {"cpp":"kBadPayload","rust":"BAD_PAYLOAD","dart":"badPayload","value":3,"wireName":"BAD_PAYLOAD","go":"PakarBadPayload"}, + {"cpp":"kHardwareUnavailable","rust":"HARDWARE_UNAVAILABLE","dart":"hardwareUnavailable","value":4,"wireName":"HARDWARE_UNAVAILABLE","go":"PakarHardwareUnavailable"}, + {"cpp":"kBusy","rust":"BUSY","dart":"busy","value":5,"wireName":"BUSY","go":"PakarBusy"}, + {"cpp":"kUnsafe","rust":"UNSAFE","dart":"unsafe","value":6,"wireName":"UNSAFE","go":"PakarUnsafe"}, + {"cpp":"kRedCellsLocked","rust":"RED_CELLS_LOCKED","dart":"redCellsLocked","value":7,"wireName":"RED_CELLS_LOCKED","go":"PakarRedCellsLocked"}, + {"cpp":"kInvalidCellAssignments","rust":"INVALID_CELL_ASSIGNMENTS","dart":"invalidCellAssignments","value":8,"wireName":"INVALID_CELL_ASSIGNMENTS","go":"PakarInvalidCellAssignments"}, + {"cpp":"kUnsupportedBoardRole","rust":"UNSUPPORTED_BOARD_ROLE","dart":"unsupportedBoardRole","value":9,"wireName":"UNSUPPORTED_BOARD_ROLE","go":"PakarUnsupportedBoardRole"} + ] + } } diff --git a/Project/UartProtocol.cpp b/Project/UartProtocol.cpp index 238c6268b..20a5a0bf5 100644 --- a/Project/UartProtocol.cpp +++ b/Project/UartProtocol.cpp @@ -157,11 +157,11 @@ void UartProtocol::processEncodedFrame() { // in RX storage leaves raw_ available for nested ACK/error/response writes. const uint8_t rawLength = WireCodec::cobsDecode( receive_, receiveLength_, receive_, sizeof(receive_)); - // The reserved byte is advisory: magic, bounded shape, CRC, and each known - // opcode's semantic validation decide whether a frame is understandable. - // This lets reduced/newer feature sets interoperate without build-specific - // branches; unknown operations still receive Unsupported from the handler. + // This fixed envelope discriminator is not a version selector. Pakar uses + // the same framing but a different command namespace (0x11 is audio, not PWM). + // Reject another envelope before semantic dispatch; never guess/fall back. if (rawLength < RawOverhead || receive_[0] != Magic || + receive_[1] != ReservedEnvelopeByte || receive_[4] > MaximumPayload || rawLength != static_cast(receive_[4] + RawOverhead)) { if (framingErrors_ != UINT16_MAX) { diff --git a/Tools/Build/generate-protocol-contract.mjs b/Tools/Build/generate-protocol-contract.mjs index 90dfa0d74..b22f86295 100644 --- a/Tools/Build/generate-protocol-contract.mjs +++ b/Tools/Build/generate-protocol-contract.mjs @@ -6,6 +6,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { loadProjectEnv } from "./env.mjs"; +import {execFileSync} from "node:child_process"; loadProjectEnv(); @@ -17,265 +18,8 @@ export const goOutputPath = resolve( "../Controller/internal/native/protocol_contract_generated.go", ); -const identifier = /^[A-Za-z][A-Za-z0-9]*$/; -const wireName = /^[A-Z][A-Z0-9_]*$/; -const requiredOpcodes = new Map([ - ["HostMenuDirectory", 0x42], - ["HostMenuContent", 0x43], - ["HostMenuStateGet", 0x44], -]); -const requiredErrors = new Map([ - ["NoError", 0], - ["BadEnvelope", 1], - ["Unsupported", 2], - ["BadPayload", 3], - ["HardwareUnavailable", 4], - ["Busy", 5], - ["Unsafe", 6], -]); - -function requireInteger(value, label, minimum = 0, maximum = 0xFF) { - if (!Number.isInteger(value) || value < minimum || value > maximum) { - throw new Error(`${label} must be an integer in ${minimum}..${maximum}`); - } -} - -function requireIdentifier(value, label) { - if (typeof value !== "string" || !identifier.test(value)) { - throw new Error(`${label} must be an identifier`); - } -} - -function requireWireName(value, label) { - if (typeof value !== "string" || !wireName.test(value)) { - throw new Error(`${label} must be an upper-case wire name`); - } -} - -function validateRegistry(entries, label, fields) { - if (!Array.isArray(entries) || entries.length === 0) { - throw new Error(`${label} must be a non-empty array`); - } - const values = new Set(); - const cpp = new Set(); - const go = new Set(); - let previous = -1; - for (const [index, entry] of entries.entries()) { - if (entry === null || typeof entry !== "object") { - throw new Error(`${label}[${index}] must be an object`); - } - requireInteger(entry.value, `${label}[${index}].value`); - requireIdentifier(entry.cpp, `${label}[${index}].cpp`); - requireIdentifier(entry.go, `${label}[${index}].go`); - if (fields.includes("wireName")) { - requireWireName(entry.wireName, `${label}[${index}].wireName`); - } - if (values.has(entry.value) || cpp.has(entry.cpp) || go.has(entry.go)) { - throw new Error(`${label}[${index}] duplicates a value or exported name`); - } - if (entry.value <= previous) { - throw new Error(`${label} must be strictly ordered by value`); - } - values.add(entry.value); - cpp.add(entry.cpp); - go.add(entry.go); - previous = entry.value; - } - return { values, cpp, go }; -} - -// validateContract rejects ambiguous registries before either public target is -// rendered. The exact physical extensions are guarded here instead of relying on -// a reviewer to notice a hexadecimal value changing in a generated diff. -export function validateContract(contract) { - if (contract === null || typeof contract !== "object") { - throw new Error("protocol contract must be an object"); - } - const envelope = contract.envelope; - if (envelope === null || typeof envelope !== "object") { - throw new Error("protocol contract requires an envelope object"); - } - requireInteger(envelope.magic, "envelope.magic"); - requireInteger(envelope.reserved, "envelope.reserved"); - requireInteger(envelope.maximumPayload, "envelope.maximumPayload", 1, 247); - requireInteger(envelope.rawFrameOverhead, "envelope.rawFrameOverhead", 6, 16); - requireInteger( - envelope.maximumEncodedFrameOverhead, - "envelope.maximumEncodedFrameOverhead", - 2, - 16, - ); - - const opcodeRegistry = validateRegistry(contract.opcodes, "opcodes", ["wireName"]); - const errorRegistry = validateRegistry(contract.errors, "errors", []); - const reserved = contract.reservedOpcodes ?? []; - if (!Array.isArray(reserved)) { - throw new Error("reservedOpcodes must be an array"); - } - const reservedValues = new Set(); - for (const [index, entry] of reserved.entries()) { - if (entry === null || typeof entry !== "object") { - throw new Error(`reservedOpcodes[${index}] must be an object`); - } - requireInteger(entry.value, `reservedOpcodes[${index}].value`); - if (typeof entry.note !== "string" || entry.note.trim() === "") { - throw new Error(`reservedOpcodes[${index}].note must be descriptive`); - } - if (opcodeRegistry.values.has(entry.value) || reservedValues.has(entry.value)) { - throw new Error(`reserved opcode 0x${formatHex(entry.value)} conflicts`); - } - reservedValues.add(entry.value); - } - for (const [name, value] of requiredOpcodes) { - const entry = contract.opcodes.find((candidate) => candidate.cpp === name); - if (!entry || entry.value !== value) { - throw new Error(`${name} must remain 0x${formatHex(value)}`); - } - } - for (const [name, value] of requiredErrors) { - const entry = contract.errors.find((candidate) => candidate.cpp === name); - if (!entry || entry.value !== value) { - throw new Error(`${name} must remain ${value}`); - } - } - if (envelope.magic !== 0xA5 || envelope.reserved !== 1 || envelope.maximumPayload !== 48) { - throw new Error("the published native envelope must remain A5/reserved-1/48-byte payload"); - } - return contract; -} - -function formatHex(value) { - return value.toString(16).toUpperCase().padStart(2, "0"); -} - -function generatedBanner(source) { - return [ - "// Code generated by Tools/Build/generate-protocol-contract.mjs; DO NOT EDIT.", - `// Source: ${source}`, - "", - ]; -} - -function cppEnum(entries, label) { - const lines = [`enum ${label} : uint8_t {`]; - for (const entry of entries) { - lines.push(` ${entry.cpp} = 0x${formatHex(entry.value)},`); - } - lines.push("};", ""); - return lines; -} - -function goConstBlock(entries, valueFor) { - const width = Math.max(...entries.map((entry) => entry.name.length)); - return [ - "const (", - ...entries.map( - (entry) => `\t${entry.name.padEnd(width)}${entry.type ? ` ${entry.type}` : ""} = ${valueFor(entry)}`, - ), - ")", - ]; -} - -// renderCpp keeps the established ControllerProtocol::* names and C++11-only -// surface. AVR receives no allocation, clock, or transport dependency here. -export function renderCpp(contract) { - validateContract(contract); - const { envelope, opcodes, reservedOpcodes, errors } = contract; - const reservations = new Map(reservedOpcodes.map((entry) => [entry.value, entry.note])); - const lines = [ - "#pragma once", - "", - ...generatedBanner("Project/ProtocolContract.json"), - "// Target-neutral native wire contract. Keep numeric values stable;", - "// capabilities advertise optional support rather than renumbering operations.", - "", - "#include ", - "", - "namespace ControllerProtocol {", - "namespace WireContract {", - "", - `constexpr uint8_t Magic = 0x${formatHex(envelope.magic)};`, - `constexpr uint8_t ReservedEnvelopeByte = ${envelope.reserved};`, - `constexpr uint8_t MaximumPayload = ${envelope.maximumPayload};`, - `constexpr uint8_t RawFrameOverhead = ${envelope.rawFrameOverhead};`, - "constexpr uint8_t MaximumRawFrame = MaximumPayload + RawFrameOverhead;", - `constexpr uint8_t MaximumEncodedFrame = MaximumRawFrame + ${envelope.maximumEncodedFrameOverhead};`, - "", - "// Opcode is the stable native request, response, and event registry.", - "enum Opcode : uint8_t {", - ]; - for (const entry of opcodes) { - lines.push(` ${entry.cpp} = 0x${formatHex(entry.value)},`); - const nextReservation = reservations.get(entry.value + 1); - if (nextReservation) { - lines.push(` // 0x${formatHex(entry.value + 1)} is reserved: ${nextReservation}`); - } - } - lines.push("};", "", "// Error is the compact failure code returned by ErrorResponse."); - lines.push(...cppEnum(errors, "Error")); - lines.push( - "static_assert(MaximumRawFrame < 254,", - " \"native COBS contract requires one bounded code block\");", - "static_assert(HostMenuDirectory == 0x42 && HostMenuContent == 0x43 &&", - " HostMenuStateGet == 0x44 && StatusLedChanged == 0x9E,", - " \"native opcode values are part of the wire contract\");", - "", - "} // namespace WireContract", - "", - "// Preserve the established ControllerProtocol::* source API without", - "// duplicating the generated constants in AVR storage.", - "using namespace WireContract;", - "", - "} // namespace ControllerProtocol", - "", - ); - return lines.join("\n"); -} - -// renderGo preserves the existing public native.Op* and Error* spellings so -// callers retain source compatibility while values gain one checked-in owner. -export function renderGo(contract) { - validateContract(contract); - const { envelope, opcodes, errors } = contract; - const lines = [ - ...generatedBanner("Project/ProtocolContract.json"), - "package native", - "", - ...goConstBlock( - [ - { name: "Magic", type: "byte", value: `0x${formatHex(envelope.magic)}` }, - { name: "ReservedEnvelopeByte", type: "byte", value: `${envelope.reserved}` }, - ], - (entry) => entry.value, - ), - "", - ...goConstBlock( - [ - { name: "MaxPayload", value: `${envelope.maximumPayload}` }, - { name: "RawFrameOverhead", value: `${envelope.rawFrameOverhead}` }, - { name: "MaxRawFrame", value: "MaxPayload + RawFrameOverhead" }, - { name: "MaxEncodedFrame", value: `MaxRawFrame + ${envelope.maximumEncodedFrameOverhead}` }, - ], - (entry) => entry.value, - ), - "", - ...goConstBlock( - opcodes.map((entry) => ({ - name: entry.go, - type: "byte", - value: `0x${formatHex(entry.value)}`, - })), - (entry) => entry.value, - ), - "", - ...goConstBlock( - errors.map((entry) => ({ name: entry.go, type: "byte", value: `${entry.value}` })), - (entry) => entry.value, - ), - "", - ]; - return lines.join("\n"); -} +import {validateContract, renderCpp, renderGo, validatePublishedBindings} from "../../interfaces/controller-wire/registry.mjs"; +export {validateContract, renderCpp, renderGo} from "../../interfaces/controller-wire/registry.mjs"; async function readContract() { const source = await readFile(sourcePath, "utf8"); @@ -299,7 +43,25 @@ async function checkOutput(path, expected) { // syncProtocolContract writes both checked-in target views, or verifies that a // clean checkout contains exactly the deterministic generator output. export async function syncProtocolContract({ check = false } = {}) { - const contract = validateContract(await readContract()); + const contract = validatePublishedBindings(await readContract(), JSON.parse(await readFile(resolve(scriptDirectory, "../../interfaces/controller-wire/published.json"), "utf8"))); + // Check the protected base as well as the fixture. Updating a fixture and + // regenerating both languages must not make a published renumbering legal. + if (check && process.env.GITHUB_EVENT_PATH) { + const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")); + const base = event.pull_request?.base?.sha ?? event.before; + if (base && /^[0-9a-f]{40}$/.test(base) && !/^0+$/.test(base)) { + const root = resolve(scriptDirectory, "../.."); + let previous; + try { previous = execFileSync("git", ["show", base + ":Project/ProtocolContract.json"], {cwd:root,encoding:"utf8",stdio:["ignore","pipe","pipe"]}); } + catch { + execFileSync("git", ["fetch", "--no-tags", "--depth=1", "origin", base], {cwd:root,stdio:"pipe"}); + previous = execFileSync("git", ["show", base + ":Project/ProtocolContract.json"], {cwd:root,encoding:"utf8"}); + } + const published = JSON.parse(previous); + validatePublishedBindings(contract, {controller:published, pakar:published.pakar ?? + {envelope:contract.pakar.envelope, opcodes:[], errors:[], reservedOpcodes:[]}}); + } + } const cpp = renderCpp(contract); const go = renderGo(contract); if (check) { diff --git a/Tools/Build/protocol-contract-generator.test.mjs b/Tools/Build/protocol-contract-generator.test.mjs index fe53b5cc7..c775831d1 100644 --- a/Tools/Build/protocol-contract-generator.test.mjs +++ b/Tools/Build/protocol-contract-generator.test.mjs @@ -11,6 +11,7 @@ import { syncProtocolContract, validateContract, } from "./generate-protocol-contract.mjs"; +import {validatePublishedBindings, renderPakarCpp, renderRust, renderDart} from "../../interfaces/controller-wire/registry.mjs"; async function loadContract() { return JSON.parse(await readFile(sourcePath, "utf8")); @@ -53,3 +54,38 @@ test("registry validation rejects extension or error-number drift", async () => wrongError.errors.find((entry) => entry.cpp === "Unsafe").value = 7; assert.throws(() => validateContract(wrongError), /Unsafe must remain 6/); }); + +test("every published opcode and error is append-only in each explicit namespace", async () => { + const source = await loadContract(); + const published = JSON.parse(await readFile(new URL("../../interfaces/controller-wire/published.json", import.meta.url), "utf8")); + validatePublishedBindings(source, published); + for (const namespace of ["controller", "pakar"]) { + for (const kind of ["opcodes", "errors"]) { + for (const entry of published[namespace][kind]) { + const changed = structuredClone(source); + const registry = namespace === "controller" ? changed : changed.pakar; + registry[kind].find(row => row.cpp === entry.cpp).value = 0xFC; + registry[kind].sort((a,b) => a.value-b.value); + assert.throws(() => validatePublishedBindings(changed, published), undefined, namespace + "/" + entry.cpp); + const removed = structuredClone(source); + const target = namespace === "controller" ? removed : removed.pakar; + target[kind] = target[kind].filter(row => row.cpp !== entry.cpp); + assert.throws(() => validatePublishedBindings(removed, published)); + } + } + } + const extended = structuredClone(source); + extended.pakar.opcodes.push({cpp:"kOptionalProbe",go:"PakarOptionalProbe",rust:"OPTIONAL_PROBE",dart:"optionalProbe",value:0xFC,wireName:"OPTIONAL_PROBE"}); + validatePublishedBindings(extended, published); // Optional additions do not select a generation. +}); + +test("all consumer views preserve the audio/PWM namespace boundary", async () => { + const contract = await loadContract(); + assert.equal(contract.opcodes.find(row => row.cpp === "PwmSet").value, 0x11); + assert.equal(contract.pakar.opcodes.find(row => row.cpp === "kAudioCue").value, 0x11); + assert.notEqual(contract.envelope.reserved, contract.pakar.envelope.reserved); + assert.match(renderPakarCpp(contract), /kAudioCue = 0x11/); + assert.match(renderRust(contract), /pub mod pakar[\s\S]*AUDIO_CUE: u8 = 0x11/); + assert.match(renderRust(contract), /pub mod controller[\s\S]*PWM_SET: u8 = 0x11/); + assert.match(renderDart(contract), /class PakarOpcode[\s\S]*audioCue = 0x11/); +}); diff --git a/Tools/Build/sync-controller-opcodes.mjs b/Tools/Build/sync-controller-opcodes.mjs new file mode 100644 index 000000000..078cec204 --- /dev/null +++ b/Tools/Build/sync-controller-opcodes.mjs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// Explicit source sync only. No board, toolchain, process or deployment actions. +import assert from "node:assert/strict"; +import {readFile, writeFile, mkdir} from "node:fs/promises"; +import {resolve, dirname} from "node:path"; +import {fileURLToPath} from "node:url"; +import {execFileSync} from "node:child_process"; +import {createHash} from "node:crypto"; +import {loadProjectEnv} from "./env.mjs"; +loadProjectEnv(); +const root = fileURLToPath(new URL("../../", import.meta.url)); +const args = process.argv.slice(2); +assert(args.length === 2 && ["--pakar-root", "--pealayer-root"].includes(args[0]), "Use --pakar-root PATH or --pealayer-root PATH"); +const pakar = args[0] === "--pakar-root", target = resolve(args[1]); +assert.notEqual(target.toLowerCase(), resolve(root).toLowerCase()); +await readFile(resolve(target, pakar ? "libraries/BoardCore/src/Protocol.h" : "src/four_d/protocol.rs")); +const sources = ["Project/ProtocolContract.json", ...["registry.mjs", "published.json", "consumer.mjs"].map(n => "interfaces/controller-wire/" + n)]; +execFileSync("git", ["diff", "--exit-code", "HEAD", "--", ...sources], {cwd: root, stdio: "pipe"}); +const commit = execFileSync("git", ["rev-parse", "HEAD"], {cwd: root, encoding: "utf8"}).trim(); +const previousPath = resolve(target, "interfaces/controller-wire/source-lock.json"); +let previous; try { previous = JSON.parse(await readFile(previousPath, "utf8")); } catch (e) { if (e.code !== "ENOENT") throw e; } +const hash = bytes => createHash("sha256").update(bytes).digest("hex"); +const files = {}, writes = []; +for (const source of sources) { + const destination = source === sources[0] ? (pakar ? "docs/pccontroller/ProtocolContract.json" : "interfaces/controller-wire/ProtocolContract.json") : source; + const path = resolve(target, destination), bytes = await readFile(resolve(root, source)); + let existing; try { existing = await readFile(path); } catch (e) { if (e.code !== "ENOENT") throw e; } + if (previous && existing && hash(existing) !== previous.files[source] && hash(existing) !== hash(bytes)) + throw Error("Unsynchronized local registry edits must be reviewed first: " + destination); + files[source] = hash(bytes); + if (!existing || !existing.equals(bytes)) writes.push([path, bytes]); +} +// Validate every destination before any write. Only these known source files +// and their provenance lock are copied; generated views belong to the consumer. +for (const [path, bytes] of writes) { await mkdir(dirname(path), {recursive:true}); await writeFile(path, bytes); } +await mkdir(dirname(previousPath), {recursive:true}); +await writeFile(previousPath, JSON.stringify({owner:"atomicdeploy/PCController", commit, files}, null, 2) + "\n"); +console.log("Synced canonical opcode sources from " + commit + "; run the selected consumer generator."); diff --git a/Tools/Controller/docs/Protocol-and-Network-API.md b/Tools/Controller/docs/Protocol-and-Network-API.md index 06258d76b..387d1aa9d 100644 --- a/Tools/Controller/docs/Protocol-and-Network-API.md +++ b/Tools/Controller/docs/Protocol-and-Network-API.md @@ -62,7 +62,7 @@ Frames are COBS encoded and terminated by `0x00`. The decoded frame is: ```text offset type meaning 0 u8 magic = 0xA5 -1 u8 reserved/advisory byte (senders currently write 1) +1 u8 fixed native namespace discriminator (1; Pakar uses 0) 2 u8 opcode 3 u8 sequence 4 u8 payload length, 0..48 @@ -73,10 +73,10 @@ offset type meaning CRC uses polynomial `0x07`, initial value `0x00`, over every decoded byte before the CRC. Multi-byte values are little-endian. -The MCU accepts a frame by canonical magic, bounded length, and CRC rather -than requiring the reserved/advisory byte to equal the local build's value. -The byte is not a protocol generation: senders preserve the current value for -stable physical framing, while readers tolerate unknown values. +The MCU and host require canonical magic, bounded length, CRC and the fixed +native namespace discriminator1. Pakar's discriminator0 is rejected before +dispatch: its0x11 means audio rather than PWM. There is no dialect guessing or +fallback. This byte is not a firmware/API generation or feature selector. Known write operations validate a required semantic payload prefix and ignore trailing extension fields. Structurally distinct record shapes retain their shape byte; unknown opcodes receive `Unsupported`. This provides loose diff --git a/Tools/Controller/internal/native/frame.go b/Tools/Controller/internal/native/frame.go index 19592ff80..8639a0b4a 100644 --- a/Tools/Controller/internal/native/frame.go +++ b/Tools/Controller/internal/native/frame.go @@ -10,6 +10,7 @@ var ( ErrMalformedCOBS = errors.New("malformed COBS frame") ErrFrameTooLong = errors.New("frame exceeds protocol limit") ErrBadMagic = errors.New("invalid frame magic") + ErrWrongProtocol = errors.New("frame belongs to a different protocol namespace") ErrBadLength = errors.New("payload length does not match frame") ErrBadCRC = errors.New("frame CRC mismatch") ErrPayloadTooLong = errors.New("payload exceeds 48 bytes") @@ -57,6 +58,9 @@ func Decode(encoded []byte) (Frame, error) { if raw[0] != Magic { return Frame{}, fmt.Errorf("%w: got 0x%02X", ErrBadMagic, raw[0]) } + if raw[1] != ReservedEnvelopeByte { + return Frame{}, fmt.Errorf("%w: discriminator 0x%02X", ErrWrongProtocol, raw[1]) + } payloadLength := int(raw[4]) if payloadLength > MaxPayload || len(raw) != 6+payloadLength { return Frame{}, fmt.Errorf( diff --git a/Tools/Controller/internal/native/frame_test.go b/Tools/Controller/internal/native/frame_test.go index 8d79f4154..080f7a52f 100644 --- a/Tools/Controller/internal/native/frame_test.go +++ b/Tools/Controller/internal/native/frame_test.go @@ -123,7 +123,7 @@ func TestDecodeRejectsCRCAndLength(t *testing.T) { } } -func TestDecodeAcceptsUnknownReservedEnvelopeByte(t *testing.T) { +func TestDecodeRejectsForeignProtocolBeforeOpcodeDispatch(t *testing.T) { encoded, err := Encode(Frame{Opcode: OpHello, Seq: 9}) if err != nil { t.Fatal(err) @@ -132,11 +132,17 @@ func TestDecodeAcceptsUnknownReservedEnvelopeByte(t *testing.T) { if err != nil { t.Fatal(err) } - raw[1] = 0x7F - raw[len(raw)-1] = CRC8(raw[:len(raw)-1]) - decoded, err := Decode(append(COBSEncode(raw), 0)) - if err != nil || decoded.Opcode != OpHello || decoded.Seq != 9 { - t.Fatalf("unknown reserved envelope byte was rejected: frame=%#v err=%v", decoded, err) + for discriminator := 0; discriminator < 256; discriminator++ { + if byte(discriminator) == ReservedEnvelopeByte { + continue + } + raw[1] = byte(discriminator) + raw[2] = OpPWMSet // Also Pakar AUDIO_CUE: never reinterpret its payload. + raw[len(raw)-1] = CRC8(raw[:len(raw)-1]) + _, err := Decode(append(COBSEncode(raw), 0)) + if !errors.Is(err, ErrWrongProtocol) { + t.Fatalf("foreign discriminator 0x%02X accepted: %v", discriminator, err) + } } } diff --git a/Tools/Controller/internal/native/opcodes.go b/Tools/Controller/internal/native/opcodes.go index 2017160c2..5a6f3969f 100644 --- a/Tools/Controller/internal/native/opcodes.go +++ b/Tools/Controller/internal/native/opcodes.go @@ -17,45 +17,6 @@ const ( ProgramStateRunning ) -var opcodeNames = map[byte]string{ - OpHello: "HELLO", OpGetStatus: "GET_STATUS", OpSetStream: "SET_STREAM", - OpGetSettings: "GET_SETTINGS", OpSetSettings: "SET_SETTINGS", - OpTemperatureList: "TEMPERATURE_LIST", - OpBuzzer: "BUZZER", OpPWMSet: "PWM_SET", OpPWMAllOff: "PWM_ALL_OFF", - OpStatusRGB: "STATUS_RGB", OpPWMGet: "PWM_GET", - OpAddressableLED: "ADDRESSABLE_LED", OpStatusEffect: "STATUS_EFFECT", - OpStatusProfileGet: "STATUS_PROFILE_GET", OpStatusProfileSet: "STATUS_PROFILE_SET", - OpRFTx: "RF_TX", OpRFLearnStart: "RF_LEARN_START", OpRFLearnCancel: "RF_LEARN_CANCEL", - OpRFLearnClear: "RF_LEARN_CLEAR", OpRFLearnList: "RF_LEARN_LIST", - OpRFLearnRemove: "RF_LEARN_REMOVE", - OpMenuAction: "MENU_ACTION", OpRelaySet: "RELAY_SET", - OpRelaySide: "RELAY_SIDE", OpRelayAllOff: "RELAY_ALL_OFF", - OpRelayTest: "RELAY_TEST", OpReset: "RESET", OpI2CTransfer: "I2C_TRANSFER", - OpMenuSetPage: "MENU_SET_PAGE", OpDisplayText: "DISPLAY_TEXT", - OpMacroStart: "MACRO_START", OpMacroCancel: "MACRO_CANCEL", - OpMacroStep: "MACRO_STEP", OpFrontPanelGet: "FRONT_PANEL_GET", - OpRemoteKeyGesture: "REMOTE_KEY_GESTURE", OpMenuList: "MENU_LIST", - OpRFLearnReplace: "RF_LEARN_REPLACE", OpMenuLayoutGet: "MENU_LAYOUT_GET", - OpMenuLayoutSet: "MENU_LAYOUT_SET", OpHostMenuDirectory: "HOST_MENU_DIRECTORY", - OpHostMenuContent: "HOST_MENU_CONTENT", OpHostMenuStateGet: "HOST_MENU_STATE_GET", - OpProgramState: "PROGRAM_STATE", OpAutomationList: "AUTOMATION_LIST", - OpAutomationPut: "AUTOMATION_PUT", OpAutomationRemove: "AUTOMATION_REMOVE", - OpAutomationClear: "AUTOMATION_CLEAR", - OpACK: "ACK", OpHelloResp: "HELLO", OpError: "ERROR", OpStatus: "STATUS", - OpSettings: "SETTINGS", OpPWMValues: "PWM_VALUES", - OpI2CTransferResp: "I2C_TRANSFER", OpRFEntries: "RF_ENTRIES", - OpTemperatures: "TEMPERATURES", OpFrontPanel: "FRONT_PANEL", - OpMenuListResp: "MENU_LIST", - OpMacroStatus: "MACRO_STATUS", - OpMenuLayoutResp: "MENU_LAYOUT", - OpHostMenuRequest: "HOST_MENU_REQUEST", OpHostMenuState: "HOST_MENU_STATE", - OpAutomationListResp: "AUTOMATION_LIST", OpAutomationRecord: "AUTOMATION_RECORD", - OpSegmentChanged: "SEGMENT_CHANGED", OpBuzzerChanged: "BUZZER_CHANGED", - OpStatusLEDChanged: "STATUS_LED_CHANGED", - OpStatusProfile: "STATUS_PROFILE", - OpEvent: "EVENT", -} - func OpcodeName(opcode byte) string { if name, ok := opcodeNames[opcode]; ok { return name diff --git a/Tools/Controller/internal/native/protocol_contract_generated.go b/Tools/Controller/internal/native/protocol_contract_generated.go index 7fff0391b..129f2e39d 100644 --- a/Tools/Controller/internal/native/protocol_contract_generated.go +++ b/Tools/Controller/internal/native/protocol_contract_generated.go @@ -96,3 +96,75 @@ const ( ErrorBusy byte = 5 ErrorUnsafe byte = 6 ) + +var opcodeNames = map[byte]string{ + OpHello: "HELLO", + OpGetStatus: "GET_STATUS", + OpSetStream: "SET_STREAM", + OpGetSettings: "GET_SETTINGS", + OpSetSettings: "SET_SETTINGS", + OpTemperatureList: "TEMPERATURE_LIST", + OpBuzzer: "BUZZER", + OpPWMSet: "PWM_SET", + OpPWMAllOff: "PWM_ALL_OFF", + OpStatusRGB: "STATUS_RGB", + OpPWMGet: "PWM_GET", + OpAddressableLED: "ADDRESSABLE_LED", + OpStatusEffect: "STATUS_EFFECT", + OpStatusProfileGet: "STATUS_PROFILE_GET", + OpStatusProfileSet: "STATUS_PROFILE_SET", + OpRFTx: "RF_TX", + OpRFLearnStart: "RF_LEARN_START", + OpRFLearnCancel: "RF_LEARN_CANCEL", + OpRFLearnClear: "RF_LEARN_CLEAR", + OpRFLearnList: "RF_LEARN_LIST", + OpRFLearnRemove: "RF_LEARN_REMOVE", + OpMenuAction: "MENU_ACTION", + OpRelaySet: "RELAY_SET", + OpRelaySide: "RELAY_SIDE", + OpRelayAllOff: "RELAY_ALL_OFF", + OpRelayTest: "RELAY_TEST", + OpReset: "RESET", + OpI2CTransfer: "I2C_TRANSFER", + OpMenuSetPage: "MENU_SET_PAGE", + OpDisplayText: "DISPLAY_TEXT", + OpMacroStart: "MACRO_START", + OpMacroCancel: "MACRO_CANCEL", + OpMacroStep: "MACRO_STEP", + OpFrontPanelGet: "FRONT_PANEL_GET", + OpRemoteKeyGesture: "REMOTE_KEY_GESTURE", + OpMenuList: "MENU_LIST", + OpRFLearnReplace: "RF_LEARN_REPLACE", + OpMenuLayoutGet: "MENU_LAYOUT_GET", + OpMenuLayoutSet: "MENU_LAYOUT_SET", + OpHostMenuDirectory: "HOST_MENU_DIRECTORY", + OpHostMenuContent: "HOST_MENU_CONTENT", + OpHostMenuStateGet: "HOST_MENU_STATE_GET", + OpProgramState: "PROGRAM_STATE", + OpAutomationList: "AUTOMATION_LIST", + OpAutomationPut: "AUTOMATION_PUT", + OpAutomationRemove: "AUTOMATION_REMOVE", + OpAutomationClear: "AUTOMATION_CLEAR", + OpACK: "ACK", + OpHelloResp: "HELLO", + OpError: "ERROR", + OpStatus: "STATUS", + OpSettings: "SETTINGS", + OpPWMValues: "PWM_VALUES", + OpI2CTransferResp: "I2C_TRANSFER", + OpRFEntries: "RF_ENTRIES", + OpTemperatures: "TEMPERATURES", + OpFrontPanel: "FRONT_PANEL", + OpMenuListResp: "MENU_LIST", + OpMacroStatus: "MACRO_STATUS", + OpMenuLayoutResp: "MENU_LAYOUT", + OpHostMenuRequest: "HOST_MENU_REQUEST", + OpHostMenuState: "HOST_MENU_STATE", + OpSegmentChanged: "SEGMENT_CHANGED", + OpBuzzerChanged: "BUZZER_CHANGED", + OpStatusLEDChanged: "STATUS_LED_CHANGED", + OpStatusProfile: "STATUS_PROFILE", + OpEvent: "EVENT", + OpAutomationListResp: "AUTOMATION_LIST", + OpAutomationRecord: "AUTOMATION_RECORD", +} diff --git a/Tools/VirtualBoard/tests/uart_protocol_test.cpp b/Tools/VirtualBoard/tests/uart_protocol_test.cpp index 74939170d..2ff8b8272 100644 --- a/Tools/VirtualBoard/tests/uart_protocol_test.cpp +++ b/Tools/VirtualBoard/tests/uart_protocol_test.cpp @@ -71,21 +71,25 @@ void captureFrame(const Frame &frame, void *context) { std::equal(before.begin(), before.end(), frame.payload); } -void testUnknownReservedEnvelopeByteDoesNotBlockSemanticFrames() { +void testForeignEnvelopeCannotReachSemanticDispatch() { HardwareSerial serial; UartProtocol protocol(serial); Capture capture; capture.protocol = &protocol; protocol.begin(115200, captureFrame, &capture); - const std::vector expected{0x41, 0x42}; - serial.feed(encode(ControllerProtocol::DisplayText, 9, expected, 0x7E)); + for (unsigned discriminator = 0; discriminator < 256; ++discriminator) { + if (discriminator == ControllerProtocol::ReservedEnvelopeByte) continue; + serial.feed(encode(ControllerProtocol::PwmSet, 9, {1}, + static_cast(discriminator))); + protocol.service(); + } + require(capture.payloads.empty(), "foreign audio opcode reached PWM dispatch"); + require(protocol.framingErrors() == 255 && protocol.crcErrors() == 0, + "foreign protocol rejection was not counted"); + serial.feed(encode(ControllerProtocol::PwmSet, 9, {1, 0, 0})); protocol.service(); - - require(capture.payloads.size() == 1 && capture.payloads[0] == expected, - "unknown reserved envelope byte blocked a valid semantic frame"); - require(protocol.framingErrors() == 0 && protocol.crcErrors() == 0, - "reserved envelope byte advanced an envelope error counter"); + require(capture.payloads.size() == 1, "foreign frame poisoned the next valid frame"); } void testUnknownOptionalOpcodeReachesSemanticDispatch() { @@ -266,7 +270,7 @@ void testBuzzerPushCarriesMCUTimestamp() { int main() { try { testRepresentativeAndMaximumPayloads(); - testUnknownReservedEnvelopeByteDoesNotBlockSemanticFrames(); + testForeignEnvelopeCannotReachSemanticDispatch(); testUnknownOptionalOpcodeReachesSemanticDispatch(); testInvalidFramesAreRejected(); testMacroScratchCannotCorruptSplitSerialFrame(); diff --git a/docs/Controller-Convergence.md b/docs/Controller-Convergence.md index e3b9c3293..0d00db92f 100644 --- a/docs/Controller-Convergence.md +++ b/docs/Controller-Convergence.md @@ -3,7 +3,7 @@ Linked work: [PCController#702](https://github.com/atomicdeploy/PCController/issues/702) and [PakarController#41](https://github.com/atomicdeploy/PakarController/issues/41). The detailed comparison and phased decision live in PakarController's -[TRANSPORT-CONVERGENCE.md](https://github.com/atomicdeploy/PakarController/blob/codex/transport-convergence/docs/TRANSPORT-CONVERGENCE.md). +[TRANSPORT-CONVERGENCE.md](https://github.com/atomicdeploy/PakarController/blob/main/docs/TRANSPORT-CONVERGENCE.md). Existing issues103/227/18/125/375 retain their firmware/MCU/contract ownership. ## This source checkpoint @@ -22,6 +22,15 @@ Future convergence uses a living capability/service contract, not version-select adapters, exact-build equality or a third independent dispatcher. CRC is not auth; unknown required operations fail closed and uncertain mutations are not replayed. +The opcode follow-up now owns both namespace allocations in +Project/ProtocolContract.json. The portable generator emits the actual C++, Go, +Rust and Dart views; consumer imports are pinned to exact canonical source bytes +and commit. Independent published-allocation fixtures and CI base comparisons +reject renumbering, removal or reuse. Fixed A5/1 native versus A5/0 Pakar admission +is mandatory before dispatch; no fallback reinterprets one namespace as the other. +Unknown optional operations inside the correct namespace remain supported by the +normal Unsupported path. This does not unify the distinct payload semantics. + ## Real target inventory, not hypothetical ports `node Tools/Firmware/targets.mjs list` reports the existing primary ATmega328P diff --git a/docs/Front-Panel-and-Menus.md b/docs/Front-Panel-and-Menus.md index 7cef81f9b..3ca677d95 100644 --- a/docs/Front-Panel-and-Menus.md +++ b/docs/Front-Panel-and-Menus.md @@ -442,7 +442,7 @@ authored value; the exact toolchain lock retains only its generated copy. UART0 is always enabled at 115200 baud and is the primary application link, not a debug-text console. Application frames use zero-delimited COBS, magic -`0xA5`, a reserved/advisory byte (currently written as `1`), CRC-8, sequence IDs, opcodes, and a maximum 48-byte +`0xA5`, the fixed native namespace discriminator `1`, CRC-8, sequence IDs, opcodes, and a maximum 48-byte payload. Timed events and ACKs append the MCU `micros()` timestamp so the host can distinguish device execution time from USB/network arrival time. Firmware starts UART and emits an early `HELLO` before slower peripheral initialization, diff --git a/interfaces/controller-wire/README.md b/interfaces/controller-wire/README.md new file mode 100644 index 000000000..1c3f3f8a7 --- /dev/null +++ b/interfaces/controller-wire/README.md @@ -0,0 +1,31 @@ +# Stable controller opcode ownership + +Project/ProtocolContract.json is the source owner for the native and Pakar +namespaces. registry.mjs is the portable implementation adapted from the existing +PCController generator; the original build entrypoint remains its thin wrapper. +Numeric opcode names, C++/Go views, Rust/Dart views and Go log names are generated. +Never add a hand-maintained numeric opcode table in a consumer. + +Published assignments are append-only. published.json is an independent golden +compatibility fixture, not generator output. CI also checks every existing +assignment against the actual pull-request/push base so changing the fixture +and regenerating cannot authorize incompatible renumbering or removal. +Keep retired allocations reserved; advertise optional support using capabilities. + +Native frames retain A5/1 and Pakar frames retain A5/0. Those fixed discriminator +bytes are namespace admission, not versions. Every decoder must reject another +namespace before semantic dispatch. Unknown operations within the admitted +namespace remain Unsupported; never reinterpret them through another dispatcher. +The existing explicit Pakar PC-contract tunnel remains the only such crossing. +CRC detects corruption and is not authentication or command authorization. + +After committing canonical sources, use Tools/Build/sync-controller-opcodes.mjs +with --pakar-root PATH or --pealayer-root PATH. It preserves modified imported +sources rather than overwriting a draft. Each consumer receives exact source +hashes and the immutable owner commit, then runs its consumer.mjs with --pakar +or --pealayer. --check never writes. --upstream additionally verifies pinned bytes +against that exact public GitHub commit without executing downloaded code. + +Source/fixture checks do not prove deployed firmware, electrical output, timing +or hardware interoperability. See docs/Controller-Convergence.md and the linked +cross-repository issues for remaining transport and MCU acceptance. diff --git a/interfaces/controller-wire/consumer.mjs b/interfaces/controller-wire/consumer.mjs new file mode 100644 index 000000000..860fcd4d0 --- /dev/null +++ b/interfaces/controller-wire/consumer.mjs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Exact, pinned consumer of the PCController-owned registry. Never downloads code +// to execute it; optional upstream verification compares immutable source bytes. +import assert from "node:assert/strict"; +import {readFile, writeFile} from "node:fs/promises"; +import {createHash} from "node:crypto"; +import {fileURLToPath} from "node:url"; +import {validatePublishedBindings, renderCpp, renderPakarCpp, renderRust, renderDart} from "./registry.mjs"; + +const root = new URL("../../", import.meta.url); +const args = process.argv.slice(2); +assert(args.every(arg => ["--pakar", "--pealayer", "--check", "--upstream"].includes(arg)), "Unknown opcode generator argument"); +assert.equal(args.includes("--pakar") !== args.includes("--pealayer"), true, "Select exactly one consumer"); +const pakar = args.includes("--pakar"); +const snapshot = pakar ? "docs/pccontroller/ProtocolContract.json" : "interfaces/controller-wire/ProtocolContract.json"; +const read = path => readFile(new URL(path, root), "utf8"); +const digest = bytes => createHash("sha256").update(bytes).digest("hex"); +const lock = JSON.parse(await read("interfaces/controller-wire/source-lock.json")); +assert.equal(lock.owner, "atomicdeploy/PCController"); +assert.match(lock.commit, /^[0-9a-f]{40}$/); +const sources = [ + ["Project/ProtocolContract.json", snapshot], + ...["registry.mjs", "published.json", "consumer.mjs"].map(name => ["interfaces/controller-wire/" + name, "interfaces/controller-wire/" + name]) +]; +assert.deepEqual(Object.keys(lock.files).sort(), sources.map(([source]) => source).sort(), "Unrecognized registry source"); +for (const [source, destination] of sources) { + const bytes = await readFile(new URL(destination, root)); + assert.equal(digest(bytes), lock.files[source], "Pinned registry source changed: " + destination); + if (args.includes("--upstream")) { + const response = await fetch("https://raw.githubusercontent.com/atomicdeploy/PCController/" + lock.commit + "/" + source, + {signal: AbortSignal.timeout(15000), redirect: "error"}); + assert(response.ok, "Cannot verify canonical opcode source: HTTP" + response.status); + assert.equal(digest(new Uint8Array(await response.arrayBuffer())), lock.files[source], "Canonical opcode owner differs: " + source); + } +} +const contract = validatePublishedBindings(JSON.parse(await read(snapshot)), JSON.parse(await read("interfaces/controller-wire/published.json"))); +const outputs = pakar ? [ + ["libraries/BoardCore/src/ProtocolOpcodes.h", renderPakarCpp(contract)], + ["libraries/BoardCore/src/pccontroller/ProtocolContract.h", renderCpp(contract)], + ["desktop/src/protocol_opcodes.rs", renderRust(contract)], + ["mobile/lib/protocol_opcodes.dart", renderDart(contract)] +] : [["src/four_d/protocol_opcodes.rs", renderRust(contract)]]; +for (const [path, expected] of outputs) { + let actual = ""; try { actual = await read(path); } catch (error) { if (error.code !== "ENOENT") throw error; } + if (actual.replaceAll("\r\n", "\n") === expected) continue; + assert(!args.includes("--check"), "Generated opcodes are stale: " + path); + await writeFile(new URL(path, root), expected); +} +console.log("Canonical opcode ownership, published allocations and all selected consumer views agree."); diff --git a/interfaces/controller-wire/published.json b/interfaces/controller-wire/published.json new file mode 100644 index 000000000..66af0f3e3 --- /dev/null +++ b/interfaces/controller-wire/published.json @@ -0,0 +1,164 @@ +{ + "controller": { + "envelope": {"magic":165,"reserved":1,"maximumPayload":48,"rawFrameOverhead":6,"maximumEncodedFrameOverhead":2}, + "opcodes": [ + {"cpp":"Hello","go":"OpHello","value":1,"wireName":"HELLO"}, + {"cpp":"GetStatus","go":"OpGetStatus","value":2,"wireName":"GET_STATUS"}, + {"cpp":"SetStreamPeriod","go":"OpSetStream","value":3,"wireName":"SET_STREAM"}, + {"cpp":"GetSettings","go":"OpGetSettings","value":4,"wireName":"GET_SETTINGS"}, + {"cpp":"SetSettings","go":"OpSetSettings","value":5,"wireName":"SET_SETTINGS"}, + {"cpp":"TemperatureList","go":"OpTemperatureList","value":6,"wireName":"TEMPERATURE_LIST"}, + {"cpp":"Buzzer","go":"OpBuzzer","value":16,"wireName":"BUZZER"}, + {"cpp":"PwmSet","go":"OpPWMSet","value":17,"wireName":"PWM_SET"}, + {"cpp":"PwmAllOff","go":"OpPWMAllOff","value":18,"wireName":"PWM_ALL_OFF"}, + {"cpp":"StatusRgb","go":"OpStatusRGB","value":20,"wireName":"STATUS_RGB"}, + {"cpp":"PwmGet","go":"OpPWMGet","value":21,"wireName":"PWM_GET"}, + {"cpp":"AddressableLed","go":"OpAddressableLED","value":22,"wireName":"ADDRESSABLE_LED"}, + {"cpp":"StatusEffect","go":"OpStatusEffect","value":23,"wireName":"STATUS_EFFECT"}, + {"cpp":"StatusProfileGet","go":"OpStatusProfileGet","value":24,"wireName":"STATUS_PROFILE_GET"}, + {"cpp":"StatusProfileSet","go":"OpStatusProfileSet","value":25,"wireName":"STATUS_PROFILE_SET"}, + {"cpp":"RadioTransmit","go":"OpRFTx","value":32,"wireName":"RF_TX"}, + {"cpp":"RadioLearnStart","go":"OpRFLearnStart","value":33,"wireName":"RF_LEARN_START"}, + {"cpp":"RadioLearnCancel","go":"OpRFLearnCancel","value":34,"wireName":"RF_LEARN_CANCEL"}, + {"cpp":"RadioLearnClear","go":"OpRFLearnClear","value":35,"wireName":"RF_LEARN_CLEAR"}, + {"cpp":"RadioLearnList","go":"OpRFLearnList","value":36,"wireName":"RF_LEARN_LIST"}, + {"cpp":"RadioLearnRemove","go":"OpRFLearnRemove","value":37,"wireName":"RF_LEARN_REMOVE"}, + {"cpp":"MenuAction","go":"OpMenuAction","value":48,"wireName":"MENU_ACTION"}, + {"cpp":"RelaySet","go":"OpRelaySet","value":49,"wireName":"RELAY_SET"}, + {"cpp":"RelaySide","go":"OpRelaySide","value":50,"wireName":"RELAY_SIDE"}, + {"cpp":"RelayAllOff","go":"OpRelayAllOff","value":51,"wireName":"RELAY_ALL_OFF"}, + {"cpp":"RelayTest","go":"OpRelayTest","value":52,"wireName":"RELAY_TEST"}, + {"cpp":"Reset","go":"OpReset","value":53,"wireName":"RESET"}, + {"cpp":"I2cTransfer","go":"OpI2CTransfer","value":54,"wireName":"I2C_TRANSFER"}, + {"cpp":"MenuSetPage","go":"OpMenuSetPage","value":55,"wireName":"MENU_SET_PAGE"}, + {"cpp":"DisplayText","go":"OpDisplayText","value":56,"wireName":"DISPLAY_TEXT"}, + {"cpp":"MacroStart","go":"OpMacroStart","value":57,"wireName":"MACRO_START"}, + {"cpp":"MacroCancel","go":"OpMacroCancel","value":58,"wireName":"MACRO_CANCEL"}, + {"cpp":"MacroStep","go":"OpMacroStep","value":59,"wireName":"MACRO_STEP"}, + {"cpp":"FrontPanelGet","go":"OpFrontPanelGet","value":60,"wireName":"FRONT_PANEL_GET"}, + {"cpp":"RemoteKeyGesture","go":"OpRemoteKeyGesture","value":61,"wireName":"REMOTE_KEY_GESTURE"}, + {"cpp":"MenuList","go":"OpMenuList","value":62,"wireName":"MENU_LIST"}, + {"cpp":"RadioLearnReplace","go":"OpRFLearnReplace","value":63,"wireName":"RF_LEARN_REPLACE"}, + {"cpp":"MenuLayoutGet","go":"OpMenuLayoutGet","value":64,"wireName":"MENU_LAYOUT_GET"}, + {"cpp":"MenuLayoutSet","go":"OpMenuLayoutSet","value":65,"wireName":"MENU_LAYOUT_SET"}, + {"cpp":"HostMenuDirectory","go":"OpHostMenuDirectory","value":66,"wireName":"HOST_MENU_DIRECTORY"}, + {"cpp":"HostMenuContent","go":"OpHostMenuContent","value":67,"wireName":"HOST_MENU_CONTENT"}, + {"cpp":"HostMenuStateGet","go":"OpHostMenuStateGet","value":68,"wireName":"HOST_MENU_STATE_GET"}, + {"cpp":"ProgramState","go":"OpProgramState","value":69,"wireName":"PROGRAM_STATE"}, + {"cpp":"AutomationList","go":"OpAutomationList","value":70,"wireName":"AUTOMATION_LIST"}, + {"cpp":"AutomationPut","go":"OpAutomationPut","value":71,"wireName":"AUTOMATION_PUT"}, + {"cpp":"AutomationRemove","go":"OpAutomationRemove","value":72,"wireName":"AUTOMATION_REMOVE"}, + {"cpp":"AutomationClear","go":"OpAutomationClear","value":73,"wireName":"AUTOMATION_CLEAR"}, + {"cpp":"Ack","go":"OpACK","value":128,"wireName":"ACK"}, + {"cpp":"HelloResponse","go":"OpHelloResp","value":129,"wireName":"HELLO"}, + {"cpp":"ErrorResponse","go":"OpError","value":130,"wireName":"ERROR"}, + {"cpp":"StatusResponse","go":"OpStatus","value":144,"wireName":"STATUS"}, + {"cpp":"SettingsResponse","go":"OpSettings","value":145,"wireName":"SETTINGS"}, + {"cpp":"PwmValuesResponse","go":"OpPWMValues","value":146,"wireName":"PWM_VALUES"}, + {"cpp":"I2cTransferResponse","go":"OpI2CTransferResp","value":147,"wireName":"I2C_TRANSFER"}, + {"cpp":"RadioLearnListResponse","go":"OpRFEntries","value":148,"wireName":"RF_ENTRIES"}, + {"cpp":"TemperatureListResponse","go":"OpTemperatures","value":149,"wireName":"TEMPERATURES"}, + {"cpp":"FrontPanelResponse","go":"OpFrontPanel","value":150,"wireName":"FRONT_PANEL"}, + {"cpp":"MenuListResponse","go":"OpMenuListResp","value":151,"wireName":"MENU_LIST"}, + {"cpp":"MacroStatusResponse","go":"OpMacroStatus","value":152,"wireName":"MACRO_STATUS"}, + {"cpp":"MenuLayoutResponse","go":"OpMenuLayoutResp","value":153,"wireName":"MENU_LAYOUT"}, + {"cpp":"HostMenuContentRequest","go":"OpHostMenuRequest","value":154,"wireName":"HOST_MENU_REQUEST"}, + {"cpp":"HostMenuStateResponse","go":"OpHostMenuState","value":155,"wireName":"HOST_MENU_STATE"}, + {"cpp":"SegmentChanged","go":"OpSegmentChanged","value":156,"wireName":"SEGMENT_CHANGED"}, + {"cpp":"BuzzerChanged","go":"OpBuzzerChanged","value":157,"wireName":"BUZZER_CHANGED"}, + {"cpp":"StatusLedChanged","go":"OpStatusLEDChanged","value":158,"wireName":"STATUS_LED_CHANGED"}, + {"cpp":"StatusProfileResponse","go":"OpStatusProfile","value":159,"wireName":"STATUS_PROFILE"}, + {"cpp":"Event","go":"OpEvent","value":160,"wireName":"EVENT"}, + {"cpp":"AutomationListResponse","go":"OpAutomationListResp","value":161,"wireName":"AUTOMATION_LIST"}, + {"cpp":"AutomationRecordResponse","go":"OpAutomationRecord","value":162,"wireName":"AUTOMATION_RECORD"} + ], + "reservedOpcodes": [{"value":19,"note":"No PWM mode or auto-demo state is implemented."},{"value":38,"note":"Mappings are updated as complete records through RADIO_LEARN_REPLACE."},{"value":74,"note":"Media cells reuse DISPLAY_TEXT scheduled segments instead of a parallel physical opcode."}], + "errors": [ + {"cpp":"NoError","go":"ErrorNoError","value":0}, + {"cpp":"BadEnvelope","go":"ErrorBadEnvelope","value":1}, + {"cpp":"Unsupported","go":"ErrorUnsupported","value":2}, + {"cpp":"BadPayload","go":"ErrorBadPayload","value":3}, + {"cpp":"HardwareUnavailable","go":"ErrorHardwareUnavailable","value":4}, + {"cpp":"Busy","go":"ErrorBusy","value":5}, + {"cpp":"Unsafe","go":"ErrorUnsafe","value":6} + ] + }, + "pakar": { + "envelope": {"magic":165,"reserved":0,"maximumPayload":48,"rawFrameOverhead":6,"maximumEncodedFrameOverhead":2}, + "opcodes": [ + {"cpp":"kHello","rust":"HELLO","dart":"hello","value":1,"wireName":"HELLO","go":"PakarHello"}, + {"cpp":"kGetStatus","rust":"GET_STATUS","dart":"getStatus","value":2,"wireName":"GET_STATUS","go":"PakarGetStatus"}, + {"cpp":"kSetStreamPeriod","rust":"SET_STREAM_PERIOD","dart":"setStreamPeriod","value":3,"wireName":"SET_STREAM_PERIOD","go":"PakarSetStreamPeriod"}, + {"cpp":"kBuzzer","rust":"BUZZER","dart":"buzzer","value":16,"wireName":"BUZZER","go":"PakarBuzzer"}, + {"cpp":"kAudioCue","rust":"AUDIO_CUE","dart":"audioCue","value":17,"wireName":"AUDIO_CUE","go":"PakarAudioCue"}, + {"cpp":"kAudioStream","rust":"AUDIO_STREAM","dart":"audioStream","value":18,"wireName":"AUDIO_STREAM","go":"PakarAudioStream"}, + {"cpp":"kDisplayText","rust":"DISPLAY_TEXT","dart":"displayText","value":56,"wireName":"DISPLAY_TEXT","go":"PakarDisplayText"}, + {"cpp":"kSetMode","rust":"SET_MODE","dart":"setMode","value":75,"wireName":"SET_MODE","go":"PakarSetMode"}, + {"cpp":"kCellAction","rust":"CELL_ACTION","dart":"cellAction","value":76,"wireName":"CELL_ACTION","go":"PakarCellAction"}, + {"cpp":"kGameReset","rust":"GAME_RESET","dart":"gameReset","value":77,"wireName":"GAME_RESET","go":"PakarGameReset"}, + {"cpp":"kOutputMask","rust":"OUTPUT_MASK","dart":"outputMask","value":78,"wireName":"OUTPUT_MASK","go":"PakarOutputMask"}, + {"cpp":"kPing","rust":"PING","dart":"ping","value":79,"wireName":"PING","go":"PakarPing"}, + {"cpp":"kProgrammingState","rust":"PROGRAMMING_STATE","dart":"programmingState","value":80,"wireName":"PROGRAMMING_STATE","go":"PakarProgrammingState"}, + {"cpp":"kSetBrightness","rust":"SET_BRIGHTNESS","dart":"setBrightness","value":81,"wireName":"SET_BRIGHTNESS","go":"PakarSetBrightness"}, + {"cpp":"kSetLedPattern","rust":"SET_LED_PATTERN","dart":"setLedPattern","value":82,"wireName":"SET_LED_PATTERN","go":"PakarSetLedPattern"}, + {"cpp":"kNetworkConfig","rust":"NETWORK_CONFIG","dart":"networkConfig","value":83,"wireName":"NETWORK_CONFIG","go":"PakarNetworkConfig"}, + {"cpp":"kAccessConfig","rust":"ACCESS_CONFIG","dart":"accessConfig","value":84,"wireName":"ACCESS_CONFIG","go":"PakarAccessConfig"}, + {"cpp":"kDeviceName","rust":"DEVICE_NAME","dart":"deviceName","value":85,"wireName":"DEVICE_NAME","go":"PakarDeviceName"}, + {"cpp":"kPcContract","rust":"PC_CONTRACT","dart":"pcContract","value":86,"wireName":"PC_CONTRACT","go":"PakarPcContract"}, + {"cpp":"kPcContractChunk","rust":"PC_CONTRACT_CHUNK","dart":"pcContractChunk","value":87,"wireName":"PC_CONTRACT_CHUNK","go":"PakarPcContractChunk"}, + {"cpp":"kGetTelemetry","rust":"GET_TELEMETRY","dart":"getTelemetry","value":88,"wireName":"GET_TELEMETRY","go":"PakarGetTelemetry"}, + {"cpp":"kGetIdentity","rust":"GET_IDENTITY","dart":"getIdentity","value":89,"wireName":"GET_IDENTITY","go":"PakarGetIdentity"}, + {"cpp":"kEspPanel","rust":"ESP_PANEL","dart":"espPanel","value":90,"wireName":"ESP_PANEL","go":"PakarEspPanel"}, + {"cpp":"kAvrRxDiagnostic","rust":"AVR_RX_DIAGNOSTIC","dart":"avrRxDiagnostic","value":91,"wireName":"AVR_RX_DIAGNOSTIC","go":"PakarAvrRxDiagnostic"}, + {"cpp":"kSetLedOutput","rust":"SET_LED_OUTPUT","dart":"setLedOutput","value":92,"wireName":"SET_LED_OUTPUT","go":"PakarSetLedOutput"}, + {"cpp":"kGetTableSnapshot","rust":"GET_TABLE_SNAPSHOT","dart":"getTableSnapshot","value":93,"wireName":"GET_TABLE_SNAPSHOT","go":"PakarGetTableSnapshot"}, + {"cpp":"kTableCommand","rust":"TABLE_COMMAND","dart":"tableCommand","value":94,"wireName":"TABLE_COMMAND","go":"PakarTableCommand"}, + {"cpp":"kCellsAction","rust":"CELLS_ACTION","dart":"cellsAction","value":95,"wireName":"CELLS_ACTION","go":"PakarCellsAction"}, + {"cpp":"kShellRequest","rust":"SHELL_REQUEST","dart":"shellRequest","value":96,"wireName":"SHELL_REQUEST","go":"PakarShellRequest"}, + {"cpp":"kPeerReport","rust":"PEER_REPORT","dart":"peerReport","value":97,"wireName":"PEER_REPORT","go":"PakarPeerReport"}, + {"cpp":"kCellAssignments","rust":"CELL_ASSIGNMENTS","dart":"cellAssignments","value":98,"wireName":"CELL_ASSIGNMENTS","go":"PakarCellAssignments"}, + {"cpp":"kCellLock","rust":"CELL_LOCK","dart":"cellLock","value":99,"wireName":"CELL_LOCK","go":"PakarCellLock"}, + {"cpp":"kHostDemand","rust":"HOST_DEMAND","dart":"hostDemand","value":100,"wireName":"HOST_DEMAND","go":"PakarHostDemand"}, + {"cpp":"kPixelDiagnostic","rust":"PIXEL_DIAGNOSTIC","dart":"pixelDiagnostic","value":101,"wireName":"PIXEL_DIAGNOSTIC","go":"PakarPixelDiagnostic"}, + {"cpp":"kKeyMask","rust":"KEY_MASK","dart":"keyMask","value":102,"wireName":"KEY_MASK","go":"PakarKeyMask"}, + {"cpp":"kKeySettings","rust":"KEY_SETTINGS","dart":"keySettings","value":103,"wireName":"KEY_SETTINGS","go":"PakarKeySettings"}, + {"cpp":"kGamePress","rust":"GAME_PRESS","dart":"gamePress","value":104,"wireName":"GAME_PRESS","go":"PakarGamePress"}, + {"cpp":"kKeyPresentation","rust":"KEY_PRESENTATION","dart":"keyPresentation","value":105,"wireName":"KEY_PRESENTATION","go":"PakarKeyPresentation"}, + {"cpp":"kUsbApi","rust":"USB_API","dart":"usbApi","value":106,"wireName":"USB_API","go":"PakarUsbApi"}, + {"cpp":"kAck","rust":"ACK","dart":"ack","value":128,"wireName":"ACK","go":"PakarAck"}, + {"cpp":"kHelloResponse","rust":"HELLO_RESPONSE","dart":"helloResponse","value":129,"wireName":"HELLO_RESPONSE","go":"PakarHelloResponse"}, + {"cpp":"kErrorResponse","rust":"ERROR_RESPONSE","dart":"errorResponse","value":130,"wireName":"ERROR_RESPONSE","go":"PakarErrorResponse"}, + {"cpp":"kStatusResponse","rust":"STATUS_RESPONSE","dart":"statusResponse","value":144,"wireName":"STATUS_RESPONSE","go":"PakarStatusResponse"}, + {"cpp":"kGameState","rust":"GAME_STATE","dart":"gameState","value":163,"wireName":"GAME_STATE","go":"PakarGameState"}, + {"cpp":"kInputEvent","rust":"INPUT_EVENT","dart":"inputEvent","value":164,"wireName":"INPUT_EVENT","go":"PakarInputEvent"}, + {"cpp":"kCellEvent","rust":"CELL_EVENT","dart":"cellEvent","value":165,"wireName":"CELL_EVENT","go":"PakarCellEvent"}, + {"cpp":"kPong","rust":"PONG","dart":"pong","value":166,"wireName":"PONG","go":"PakarPong"}, + {"cpp":"kPcContractResponse","rust":"PC_CONTRACT_RESPONSE","dart":"pcContractResponse","value":176,"wireName":"PC_CONTRACT_RESPONSE","go":"PakarPcContractResponse"}, + {"cpp":"kTelemetryResponse","rust":"TELEMETRY_RESPONSE","dart":"telemetryResponse","value":177,"wireName":"TELEMETRY_RESPONSE","go":"PakarTelemetryResponse"}, + {"cpp":"kIdentityResponse","rust":"IDENTITY_RESPONSE","dart":"identityResponse","value":178,"wireName":"IDENTITY_RESPONSE","go":"PakarIdentityResponse"}, + {"cpp":"kEspPanelResponse","rust":"ESP_PANEL_RESPONSE","dart":"espPanelResponse","value":179,"wireName":"ESP_PANEL_RESPONSE","go":"PakarEspPanelResponse"}, + {"cpp":"kAvrRxDiagnosticResponse","rust":"AVR_RX_DIAGNOSTIC_RESPONSE","dart":"avrRxDiagnosticResponse","value":180,"wireName":"AVR_RX_DIAGNOSTIC_RESPONSE","go":"PakarAvrRxDiagnosticResponse"}, + {"cpp":"kResetTelemetryResponse","rust":"RESET_TELEMETRY_RESPONSE","dart":"resetTelemetryResponse","value":181,"wireName":"RESET_TELEMETRY_RESPONSE","go":"PakarResetTelemetryResponse"}, + {"cpp":"kTableSnapshotResponse","rust":"TABLE_SNAPSHOT_RESPONSE","dart":"tableSnapshotResponse","value":182,"wireName":"TABLE_SNAPSHOT_RESPONSE","go":"PakarTableSnapshotResponse"}, + {"cpp":"kTableCommandState","rust":"TABLE_COMMAND_STATE","dart":"tableCommandState","value":183,"wireName":"TABLE_COMMAND_STATE","go":"PakarTableCommandState"}, + {"cpp":"kPanelEvent","rust":"PANEL_EVENT","dart":"panelEvent","value":184,"wireName":"PANEL_EVENT","go":"PakarPanelEvent"}, + {"cpp":"kShellResponse","rust":"SHELL_RESPONSE","dart":"shellResponse","value":185,"wireName":"SHELL_RESPONSE","go":"PakarShellResponse"}, + {"cpp":"kHostDemandResponse","rust":"HOST_DEMAND_RESPONSE","dart":"hostDemandResponse","value":186,"wireName":"HOST_DEMAND_RESPONSE","go":"PakarHostDemandResponse"}, + {"cpp":"kAudioStreamResponse","rust":"AUDIO_STREAM_RESPONSE","dart":"audioStreamResponse","value":188,"wireName":"AUDIO_STREAM_RESPONSE","go":"PakarAudioStreamResponse"}, + {"cpp":"kUsbApiResponse","rust":"USB_API_RESPONSE","dart":"usbApiResponse","value":189,"wireName":"USB_API_RESPONSE","go":"PakarUsbApiResponse"} + ], + "reservedOpcodes": [], + "errors": [ + {"cpp":"kNoError","rust":"NO_ERROR","dart":"noError","value":0,"wireName":"NO_ERROR","go":"PakarNoError"}, + {"cpp":"kBadEnvelope","rust":"BAD_ENVELOPE","dart":"badEnvelope","value":1,"wireName":"BAD_ENVELOPE","go":"PakarBadEnvelope"}, + {"cpp":"kUnsupported","rust":"UNSUPPORTED","dart":"unsupported","value":2,"wireName":"UNSUPPORTED","go":"PakarUnsupported"}, + {"cpp":"kBadPayload","rust":"BAD_PAYLOAD","dart":"badPayload","value":3,"wireName":"BAD_PAYLOAD","go":"PakarBadPayload"}, + {"cpp":"kHardwareUnavailable","rust":"HARDWARE_UNAVAILABLE","dart":"hardwareUnavailable","value":4,"wireName":"HARDWARE_UNAVAILABLE","go":"PakarHardwareUnavailable"}, + {"cpp":"kBusy","rust":"BUSY","dart":"busy","value":5,"wireName":"BUSY","go":"PakarBusy"}, + {"cpp":"kUnsafe","rust":"UNSAFE","dart":"unsafe","value":6,"wireName":"UNSAFE","go":"PakarUnsafe"}, + {"cpp":"kRedCellsLocked","rust":"RED_CELLS_LOCKED","dart":"redCellsLocked","value":7,"wireName":"RED_CELLS_LOCKED","go":"PakarRedCellsLocked"}, + {"cpp":"kInvalidCellAssignments","rust":"INVALID_CELL_ASSIGNMENTS","dart":"invalidCellAssignments","value":8,"wireName":"INVALID_CELL_ASSIGNMENTS","go":"PakarInvalidCellAssignments"}, + {"cpp":"kUnsupportedBoardRole","rust":"UNSUPPORTED_BOARD_ROLE","dart":"unsupportedBoardRole","value":9,"wireName":"UNSUPPORTED_BOARD_ROLE","go":"PakarUnsupportedBoardRole"} + ] + } +} diff --git a/interfaces/controller-wire/registry.mjs b/interfaces/controller-wire/registry.mjs new file mode 100644 index 000000000..6ad49e09a --- /dev/null +++ b/interfaces/controller-wire/registry.mjs @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MIT +// Portable source owner: PCController interfaces/controller-wire/registry.mjs. +// Adapted from its actual Tools/Build/generate-protocol-contract.mjs. +// No IO, toolchain or transport dependencies. Consumers vendor exact pinned bytes. + +const identifier = /^[A-Za-z][A-Za-z0-9]*$/; +const wireName = /^[A-Z][A-Z0-9_]*$/; +const requiredOpcodes = new Map([ + ["HostMenuDirectory", 0x42], + ["HostMenuContent", 0x43], + ["HostMenuStateGet", 0x44], +]); +const requiredErrors = new Map([ + ["NoError", 0], + ["BadEnvelope", 1], + ["Unsupported", 2], + ["BadPayload", 3], + ["HardwareUnavailable", 4], + ["Busy", 5], + ["Unsafe", 6], +]); + +function requireInteger(value, label, minimum = 0, maximum = 0xFF) { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error(`${label} must be an integer in ${minimum}..${maximum}`); + } +} + +function requireIdentifier(value, label) { + if (typeof value !== "string" || !identifier.test(value)) { + throw new Error(`${label} must be an identifier`); + } +} + +function requireWireName(value, label) { + if (typeof value !== "string" || !wireName.test(value)) { + throw new Error(`${label} must be an upper-case wire name`); + } +} + +function validateRegistry(entries, label, fields) { + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + const values = new Set(); + const cpp = new Set(); + const go = new Set(); + let previous = -1; + for (const [index, entry] of entries.entries()) { + if (entry === null || typeof entry !== "object") { + throw new Error(`${label}[${index}] must be an object`); + } + requireInteger(entry.value, `${label}[${index}].value`); + requireIdentifier(entry.cpp, `${label}[${index}].cpp`); + requireIdentifier(entry.go, `${label}[${index}].go`); + if (fields.includes("wireName")) { + requireWireName(entry.wireName, `${label}[${index}].wireName`); + } + if (values.has(entry.value) || cpp.has(entry.cpp) || go.has(entry.go)) { + throw new Error(`${label}[${index}] duplicates a value or exported name`); + } + if (entry.value <= previous) { + throw new Error(`${label} must be strictly ordered by value`); + } + values.add(entry.value); + cpp.add(entry.cpp); + go.add(entry.go); + previous = entry.value; + } + return { values, cpp, go }; +} + +// validateContract rejects ambiguous registries before either public target is +// rendered. The exact physical extensions are guarded here instead of relying on +// a reviewer to notice a hexadecimal value changing in a generated diff. +export function validateContract(contract) { + if (contract === null || typeof contract !== "object") { + throw new Error("protocol contract must be an object"); + } + const envelope = contract.envelope; + if (envelope === null || typeof envelope !== "object") { + throw new Error("protocol contract requires an envelope object"); + } + requireInteger(envelope.magic, "envelope.magic"); + requireInteger(envelope.reserved, "envelope.reserved"); + requireInteger(envelope.maximumPayload, "envelope.maximumPayload", 1, 247); + requireInteger(envelope.rawFrameOverhead, "envelope.rawFrameOverhead", 6, 16); + requireInteger( + envelope.maximumEncodedFrameOverhead, + "envelope.maximumEncodedFrameOverhead", + 2, + 16, + ); + + const opcodeRegistry = validateRegistry(contract.opcodes, "opcodes", ["wireName"]); + const errorRegistry = validateRegistry(contract.errors, "errors", []); + const reserved = contract.reservedOpcodes ?? []; + if (!Array.isArray(reserved)) { + throw new Error("reservedOpcodes must be an array"); + } + const reservedValues = new Set(); + for (const [index, entry] of reserved.entries()) { + if (entry === null || typeof entry !== "object") { + throw new Error(`reservedOpcodes[${index}] must be an object`); + } + requireInteger(entry.value, `reservedOpcodes[${index}].value`); + if (typeof entry.note !== "string" || entry.note.trim() === "") { + throw new Error(`reservedOpcodes[${index}].note must be descriptive`); + } + if (opcodeRegistry.values.has(entry.value) || reservedValues.has(entry.value)) { + throw new Error(`reserved opcode 0x${formatHex(entry.value)} conflicts`); + } + reservedValues.add(entry.value); + } + for (const [name, value] of requiredOpcodes) { + const entry = contract.opcodes.find((candidate) => candidate.cpp === name); + if (!entry || entry.value !== value) { + throw new Error(`${name} must remain 0x${formatHex(value)}`); + } + } + for (const [name, value] of requiredErrors) { + const entry = contract.errors.find((candidate) => candidate.cpp === name); + if (!entry || entry.value !== value) { + throw new Error(`${name} must remain ${value}`); + } + } + if (envelope.magic !== 0xA5 || envelope.reserved !== 1 || envelope.maximumPayload !== 48) { + throw new Error("the published native envelope must remain A5/reserved-1/48-byte payload"); + } + const peer = contract.pakar; + if (!peer || !peer.envelope || peer.envelope.magic !== envelope.magic || + peer.envelope.reserved !== 0 || peer.envelope.maximumPayload !== 48 || + peer.envelope.rawFrameOverhead !== 6 || peer.envelope.maximumEncodedFrameOverhead !== 2) + throw new Error("Pakar requires its fixed A5/reserved-0/48-byte envelope; no dialect fallback"); + validateRegistry(peer.opcodes, "pakar.opcodes", ["wireName"]); + validateRegistry(peer.errors, "pakar.errors", []); + return contract; +} + +function formatHex(value) { + return value.toString(16).toUpperCase().padStart(2, "0"); +} + +function generatedBanner(source) { + return [ + "// Code generated by Tools/Build/generate-protocol-contract.mjs; DO NOT EDIT.", + `// Source: ${source}`, + "", + ]; +} + +function cppEnum(entries, label) { + const lines = [`enum ${label} : uint8_t {`]; + for (const entry of entries) { + lines.push(` ${entry.cpp} = 0x${formatHex(entry.value)},`); + } + lines.push("};", ""); + return lines; +} + +function goConstBlock(entries, valueFor) { + const width = Math.max(...entries.map((entry) => entry.name.length)); + return [ + "const (", + ...entries.map( + (entry) => `\t${entry.name.padEnd(width)}${entry.type ? ` ${entry.type}` : ""} = ${valueFor(entry)}`, + ), + ")", + ]; +} + +// renderCpp keeps the established ControllerProtocol::* names and C++11-only +// surface. AVR receives no allocation, clock, or transport dependency here. +export function renderCpp(contract) { + validateContract(contract); + const { envelope, opcodes, reservedOpcodes, errors } = contract; + const reservations = new Map(reservedOpcodes.map((entry) => [entry.value, entry.note])); + const lines = [ + "#pragma once", + "", + ...generatedBanner("Project/ProtocolContract.json"), + "// Target-neutral native wire contract. Keep numeric values stable;", + "// capabilities advertise optional support rather than renumbering operations.", + "", + "#include ", + "", + "namespace ControllerProtocol {", + "namespace WireContract {", + "", + `constexpr uint8_t Magic = 0x${formatHex(envelope.magic)};`, + `constexpr uint8_t ReservedEnvelopeByte = ${envelope.reserved};`, + `constexpr uint8_t MaximumPayload = ${envelope.maximumPayload};`, + `constexpr uint8_t RawFrameOverhead = ${envelope.rawFrameOverhead};`, + "constexpr uint8_t MaximumRawFrame = MaximumPayload + RawFrameOverhead;", + `constexpr uint8_t MaximumEncodedFrame = MaximumRawFrame + ${envelope.maximumEncodedFrameOverhead};`, + "", + "// Opcode is the stable native request, response, and event registry.", + "enum Opcode : uint8_t {", + ]; + for (const entry of opcodes) { + lines.push(` ${entry.cpp} = 0x${formatHex(entry.value)},`); + const nextReservation = reservations.get(entry.value + 1); + if (nextReservation) { + lines.push(` // 0x${formatHex(entry.value + 1)} is reserved: ${nextReservation}`); + } + } + lines.push("};", "", "// Error is the compact failure code returned by ErrorResponse."); + lines.push(...cppEnum(errors, "Error")); + lines.push( + "static_assert(MaximumRawFrame < 254,", + " \"native COBS contract requires one bounded code block\");", + "static_assert(HostMenuDirectory == 0x42 && HostMenuContent == 0x43 &&", + " HostMenuStateGet == 0x44 && StatusLedChanged == 0x9E,", + " \"native opcode values are part of the wire contract\");", + "", + "} // namespace WireContract", + "", + "// Preserve the established ControllerProtocol::* source API without", + "// duplicating the generated constants in AVR storage.", + "using namespace WireContract;", + "", + "} // namespace ControllerProtocol", + "", + ); + return lines.join("\n"); +} + +// renderGo preserves the existing public native.Op* and Error* spellings so +// callers retain source compatibility while values gain one checked-in owner. +export function renderGo(contract) { + validateContract(contract); + const { envelope, opcodes, errors } = contract; + const lines = [ + ...generatedBanner("Project/ProtocolContract.json"), + "package native", + "", + ...goConstBlock( + [ + { name: "Magic", type: "byte", value: `0x${formatHex(envelope.magic)}` }, + { name: "ReservedEnvelopeByte", type: "byte", value: `${envelope.reserved}` }, + ], + (entry) => entry.value, + ), + "", + ...goConstBlock( + [ + { name: "MaxPayload", value: `${envelope.maximumPayload}` }, + { name: "RawFrameOverhead", value: `${envelope.rawFrameOverhead}` }, + { name: "MaxRawFrame", value: "MaxPayload + RawFrameOverhead" }, + { name: "MaxEncodedFrame", value: `MaxRawFrame + ${envelope.maximumEncodedFrameOverhead}` }, + ], + (entry) => entry.value, + ), + "", + ...goConstBlock( + opcodes.map((entry) => ({ + name: entry.go, + type: "byte", + value: `0x${formatHex(entry.value)}`, + })), + (entry) => entry.value, + ), + "", + ...goConstBlock( + errors.map((entry) => ({ name: entry.go, type: "byte", value: `${entry.value}` })), + (entry) => entry.value, + ), + "", + "var opcodeNames = map[byte]string{", + ...opcodes.map(entry => `\t${entry.go}: ${" ".repeat(Math.max(...opcodes.map(row => row.go.length)) - entry.go.length)}"${entry.wireName}",`), + "}", + "", + ]; + return lines.join("\n"); +} + + +// Published assignments are an independent compatibility fixture, not another +// editable output. Additions are allowed; removal, reuse and renumbering are not. +export function validatePublishedBindings(contract, published) { + validateContract(contract); + for (const [name, current] of [["controller", contract], ["pakar", contract.pakar]]) { + const previous = published[name]; + if (!previous) throw Error("Missing published namespace " + name); + for (const [key, value] of Object.entries(previous.envelope)) + if (current.envelope[key] !== value) throw Error(name + " published envelope changed: " + key); + for (const type of ["opcodes", "errors"]) for (const entry of previous[type]) { + const actual = current[type].find(row => row.cpp === entry.cpp); + if (!actual || Object.entries(entry).some(([key, value]) => actual[key] !== value)) + throw Error(name + " published binding changed: " + entry.cpp); + } + for (const reserved of previous.reservedOpcodes) { + if (current.opcodes.some(row => row.value === reserved.value) || + !current.reservedOpcodes.some(row => row.value === reserved.value)) + throw Error(name + " published reservation reused: " + reserved.value); + } + } + return contract; +} +const upper = name => name.replace(/^k(?=[A-Z])/, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); +const dartName = name => name.replace(/^k(?=[A-Z])/, "").replace(/^./, c => c.toLowerCase()); +const banner = "// Generated from PCController Project/ProtocolContract.json; DO NOT EDIT.\n"; +export function renderPakarCpp(contract) { + validateContract(contract); + const p = contract.pakar; + return banner + "#pragma once\n#include \n\nnamespace BoardCore { namespace Protocol {\n" + + "constexpr uint8_t kMagic = 0xA5;\nconstexpr uint8_t kReserved = 0;\nconstexpr uint8_t kMaximumPayload = 48;\n" + + "constexpr uint8_t kRawOverhead = 6;\nconstexpr uint8_t kMaximumRaw = kMaximumPayload + kRawOverhead;\nconstexpr uint8_t kMaximumEncoded = kMaximumRaw + 2;\n\n" + + ["Opcode", "Error"].map((type, i) => "enum " + type + " : uint8_t {\n" + + p[i ? "errors" : "opcodes"].map(r => " " + r.cpp + " = 0x" + formatHex(r.value) + ",").join("\n") + "\n};\n").join("\n") + + "\n} } // namespace BoardCore::Protocol\n"; +} +export function renderRust(contract) { + validateContract(contract); + return banner + [["pakar", contract.pakar], ["controller", contract]].map(([name, ns]) => + "#[allow(dead_code)]\npub mod " + name + " {\n" + + " pub const MAGIC: u8 = 0x" + formatHex(ns.envelope.magic) + ";\n" + + " pub const RESERVED: u8 = " + ns.envelope.reserved + ";\n" + + " pub const MAXIMUM_PAYLOAD: usize = " + ns.envelope.maximumPayload + ";\n" + + ns.opcodes.map(r => " pub const " + upper(r.cpp) + ": u8 = 0x" + formatHex(r.value) + ";").join("\n") + + "\n}\n").join("\n"); +} +export function renderDart(contract) { + validateContract(contract); + return banner + [["Pakar", contract.pakar], ["Controller", contract]].map(([name, ns]) => + "abstract final class " + name + "Opcode {\n" + + " static const magic = " + ns.envelope.magic + ";\n" + + " static const reserved = " + ns.envelope.reserved + ";\n" + + " static const maximumPayload = " + ns.envelope.maximumPayload + ";\n" + + ns.opcodes.map(r => " static const " + dartName(r.cpp) + " = 0x" + formatHex(r.value) + ";").join("\n") + + "\n}\n").join("\n"); +} From 1098da49c48811afc83dd29ce8da92568f713605 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Fri, 30 Oct 2026 18:02:24 +0330 Subject: [PATCH 2/5] Expose generated request membership to typed consumers --- interfaces/controller-wire/registry.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/interfaces/controller-wire/registry.mjs b/interfaces/controller-wire/registry.mjs index 6ad49e09a..0ee11c41c 100644 --- a/interfaces/controller-wire/registry.mjs +++ b/interfaces/controller-wire/registry.mjs @@ -327,6 +327,7 @@ export function renderDart(contract) { " static const magic = " + ns.envelope.magic + ";\n" + " static const reserved = " + ns.envelope.reserved + ";\n" + " static const maximumPayload = " + ns.envelope.maximumPayload + ";\n" + + " static const requests = {" + ns.opcodes.filter(r => r.value < 128).map(r => r.value).join(", ") + "};\n" + ns.opcodes.map(r => " static const " + dartName(r.cpp) + " = 0x" + formatHex(r.value) + ";").join("\n") + "\n}\n").join("\n"); } From 3b8fabe6b4833262a2b76f64c2ef648c8b4c8e96 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Fri, 30 Oct 2026 18:17:38 +0330 Subject: [PATCH 3/5] Generate provisioning tool opcode bindings from the same owner --- interfaces/controller-wire/consumer.mjs | 5 +++-- interfaces/controller-wire/registry.mjs | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/interfaces/controller-wire/consumer.mjs b/interfaces/controller-wire/consumer.mjs index 860fcd4d0..4eddddb37 100644 --- a/interfaces/controller-wire/consumer.mjs +++ b/interfaces/controller-wire/consumer.mjs @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import {readFile, writeFile} from "node:fs/promises"; import {createHash} from "node:crypto"; import {fileURLToPath} from "node:url"; -import {validatePublishedBindings, renderCpp, renderPakarCpp, renderRust, renderDart} from "./registry.mjs"; +import {validatePublishedBindings, renderCpp, renderPakarCpp, renderRust, renderDart, renderPowerShell} from "./registry.mjs"; const root = new URL("../../", import.meta.url); const args = process.argv.slice(2); @@ -38,7 +38,8 @@ const outputs = pakar ? [ ["libraries/BoardCore/src/ProtocolOpcodes.h", renderPakarCpp(contract)], ["libraries/BoardCore/src/pccontroller/ProtocolContract.h", renderCpp(contract)], ["desktop/src/protocol_opcodes.rs", renderRust(contract)], - ["mobile/lib/protocol_opcodes.dart", renderDart(contract)] + ["mobile/lib/protocol_opcodes.dart", renderDart(contract)], + ["tools/ProtocolOpcodes.ps1", renderPowerShell(contract)] ] : [["src/four_d/protocol_opcodes.rs", renderRust(contract)]]; for (const [path, expected] of outputs) { let actual = ""; try { actual = await read(path); } catch (error) { if (error.code !== "ENOENT") throw error; } diff --git a/interfaces/controller-wire/registry.mjs b/interfaces/controller-wire/registry.mjs index 0ee11c41c..8659cc835 100644 --- a/interfaces/controller-wire/registry.mjs +++ b/interfaces/controller-wire/registry.mjs @@ -331,3 +331,12 @@ export function renderDart(contract) { ns.opcodes.map(r => " static const " + dartName(r.cpp) + " = 0x" + formatHex(r.value) + ";").join("\n") + "\n}\n").join("\n"); } +export function renderPowerShell(contract) { + validateContract(contract); + return "# Generated from PCController Project/ProtocolContract.json; DO NOT EDIT.\n" + + [["Pakar", contract.pakar], ["Controller", contract]].map(([name, ns]) => + "enum " + name + "Envelope { Magic = " + ns.envelope.magic + "; Reserved = " + ns.envelope.reserved + + "; MaximumPayload = " + ns.envelope.maximumPayload + " }\n" + + "enum " + name + "Opcode {\n" + ns.opcodes.map(r => + " " + r.cpp.replace(/^k(?=[A-Z])/, "") + " = " + r.value).join("\n") + "\n}\n").join("\n"); +} From b04e163da888c911f518e113fab4360c09213aff Mon Sep 17 00:00:00 2001 From: David Refoua Date: Fri, 30 Oct 2026 18:35:49 +0330 Subject: [PATCH 4/5] Apply namespace admission to native trace helpers too --- Tools/VirtualBoard/src/protocol.cpp | 3 +++ Tools/VirtualBoard/tests/native_firmware_test.cpp | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/Tools/VirtualBoard/src/protocol.cpp b/Tools/VirtualBoard/src/protocol.cpp index e1ec68ff8..e937ccf23 100644 --- a/Tools/VirtualBoard/src/protocol.cpp +++ b/Tools/VirtualBoard/src/protocol.cpp @@ -67,6 +67,9 @@ DecodeResult decode(const std::uint8_t *encoded, std::size_t length) { if (raw[0] != kMagic) { return {{}, DecodeError::Framing, "frame magic mismatch"}; } + if (raw[1] != kReservedEnvelopeByte) { + return {{}, DecodeError::Framing, "foreign protocol namespace"}; + } const std::size_t payloadLength = raw[4]; if (payloadLength > kMaximumPayload || raw.size() != payloadLength + kRawOverhead) { diff --git a/Tools/VirtualBoard/tests/native_firmware_test.cpp b/Tools/VirtualBoard/tests/native_firmware_test.cpp index 975ce470d..d6c8044b5 100644 --- a/Tools/VirtualBoard/tests/native_firmware_test.cpp +++ b/Tools/VirtualBoard/tests/native_firmware_test.cpp @@ -10,6 +10,13 @@ void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(mes uint32_t u32(const std::vector &v,size_t i) { return uint32_t(v.at(i)) | uint32_t(v.at(i+1))<<8 | uint32_t(v.at(i+2))<<16 | uint32_t(v.at(i+3))<<24; } int main() { try { + for (unsigned discriminator=0;discriminator<256;++discriminator) { + if(discriminator==wire::kReservedEnvelopeByte)continue; + std::vector raw{wire::kMagic,static_cast(discriminator),wire::PwmSet,7,1,4}; + raw.push_back(wire::crc8(raw.data(),raw.size())); + const auto encoded=wire::cobsEncode(raw.data(),raw.size()); + require(!wire::decode(encoded.data(),encoded.size()),"trace decoder accepted foreign audio/PWM namespace"); + } virtual_board::VirtualBoard board({},true); wire::StreamDecoder decoder; auto receive=[&](uint64_t us=1000) { const auto bytes=board.tick(us); return decoder.feed(bytes.data(),bytes.size()).frames; }; From 640081369ab2bb139249f8bbd8db9c46d740e5f2 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Fri, 30 Oct 2026 18:42:20 +0330 Subject: [PATCH 5/5] Retain opcode ownership and namespace safety rules --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2831561c0..bd1853d0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,13 @@ # Repository execution rules +- Project/ProtocolContract.json is the opcode owner for native and Pakar + namespaces. Keep published allocations append-only, generate every consumer + view with interfaces/controller-wire, and retain protected-base CI comparisons. + Fixed A5/1 native and A5/0 Pakar admission is mandatory before dispatch, never + a version selector or advisory byte. Unknown operations must not fall through + into another namespace or be assumed acknowledged. Sync consumers with the + guarded Tools/Build/sync-controller-opcodes.mjs helper. + - RF managers share the reception/learning contract in `docs/RF-RECEPTION-AND-LEARNING.md`: show all complete waveform identities, dim unlearned observations, preserve modal-local count baselines across refresh and use cumulative reception metadata through coalescing. Defaults are timed learning off, optional duration 60 seconds and continuous learning off. Learning settings are persistent, not competing timed/indefinite runtime actions. Keep hard-coded guided entry points hidden until pattern profiles exist. Unknown reception never grants output authority or persists a record without explicit Add/Assign. - On Windows, never run `go test` directly. It executes test programs from