From f0abe00c2fbad6771f0277bc804ea2557b042e69 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:53:34 +0330 Subject: [PATCH 01/29] fix: prevent AVR strip sender register aliasing --- .github/workflows/firmware.yml | 5 ++ Project/AddressableLeds.cpp | 9 ++- Tools/Firmware/verify-strip-waveform.mjs | 91 ++++++++++++++++++++++++ docs/STRIP-STREAMING.md | 22 ++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 Tools/Firmware/verify-strip-waveform.mjs diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 11b6405e..d3615966 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -101,6 +101,11 @@ jobs: --clean --version "$PCCONTROLLER_VERSION" + - name: "Verify linked strip output waveform" + run: >- + node Tools/Firmware/verify-strip-waveform.mjs + .build/firmware/PCController.ino_atmega328p_16000000L.lst + - name: "💾 Assert the safe 1 KiB default EEPROM image" run: >- node .github/scripts/assert-firmware-defaults.mjs diff --git a/Project/AddressableLeds.cpp b/Project/AddressableLeds.cpp index 153baf23..86c08ee6 100644 --- a/Project/AddressableLeds.cpp +++ b/Project/AddressableLeds.cpp @@ -92,9 +92,12 @@ void show() { "nop" "\n\t" "sbiw %[count], 1" "\n\t" "brne head20%=" "\n" - : [port] "+e"(port), [byte] "+r"(byte), [bit] "+r"(bit), - [next] "+r"(next), [count] "+w"(count) - : [ptr] "e"(ptr), [hi] "r"(hi), [lo] "r"(lo)); + // The loop overwrites these operands before its final use of hi/lo/port. + // Early-clobber prevents next (initially lo) from sharing lo's register. + : [byte] "+&r"(byte), [bit] "+&d"(bit), [next] "+&r"(next), + [count] "+&w"(count), [ptr] "+&e"(ptr) + : [port] "e"(port), [hi] "r"(hi), [lo] "r"(lo) + : "memory"); SREG = savedSreg; _delay_us(80); #elif defined(PCCONTROLLER_NATIVE) diff --git a/Tools/Firmware/verify-strip-waveform.mjs b/Tools/Firmware/verify-strip-waveform.mjs new file mode 100644 index 00000000..9a1504f6 --- /dev/null +++ b/Tools/Firmware/verify-strip-waveform.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Exercise the actual linked AVR bit loop, including compiler register allocation. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const listingPath = process.argv[2] +if (!listingPath) throw new Error('Usage: node Tools/Firmware/verify-strip-waveform.mjs AVR-LISTING.lst') +const listing = readFileSync(listingPath, 'utf8') +const start = listing.match(/^([0-9a-f]+) :/m) +assert.ok(start, 'linked strip bit-loop label is missing') +const instructions = [] +for (const line of listing.slice(start.index).split('\n')) { + const match = line.trimEnd().match(/^\s*([0-9a-f]+):\s+(?:(?:[0-9a-f]{2})\s+)+\s*([a-z]+)\s*([^;]*)(?:;\s*(.*))?$/) + if (!match) continue + const [, address, op, operandText, comment = ''] = match + const target = comment.match(/^0x([0-9a-f]+)/) + instructions.push({ address: parseInt(address, 16), op, args: operandText.trim().split(/\s*,\s*/), target: target && parseInt(target[1], 16) }) + if (op === 'brne') break +} +assert.equal(instructions.at(-1)?.op, 'brne', 'complete strip byte loop is required') +const byAddress = new Map(instructions.map((instruction, index) => [instruction.address, index])) +const hi = instructions[0].args[1] +const byte = instructions.find(i => i.op === 'sbrc').args[0] +const bit = instructions.find(i => i.op === 'dec').args[0] +const moves = instructions.filter(i => i.op === 'mov') +const next = moves[0].args[0] +const lo = moves[1].args[1] +const count = instructions.find(i => i.op === 'sbiw').args[0] +assert.notEqual(next, lo, `compiler aliased changing bit operand and constant low operand (${lo})`) +assert.equal(new Set([hi, lo, byte, bit, next, count]).size, 6, 'loop operands must have independent registers') + +function verify(bytes) { + const registers = { [hi]: 64, [lo]: 0, [byte]: bytes[0], [bit]: 8, [next]: 0, [count]: bytes.length } + let pointer = 1, pc = 0, cycles = 0, zero = false, carry = 0, level = 0 + const rises = [], falls = [] + for (let steps = 0; pc < instructions.length; steps++) { + assert.ok(steps < bytes.length * 8 * 30, 'strip loop did not terminate') + const instruction = instructions[pc] + const [a, b] = instruction.args + let advance = 1, elapsed = 1 + switch (instruction.op) { + case 'st': { + elapsed = 2 + const newLevel = registers[b] & 64 + if (newLevel !== level) (newLevel ? rises : falls).push(cycles + elapsed) + level = newLevel + break + } + case 'sbrc': if (!(registers[a] & (1 << Number(b)))) { advance = 2; elapsed = 2 }; break + case 'mov': registers[a] = registers[b]; break + case 'dec': registers[a] = (registers[a] - 1) & 255; zero = registers[a] === 0; break + case 'breq': + case 'brne': + if (instruction.op === 'breq' ? zero : !zero) { + advance = byAddress.get(instruction.target) - pc + elapsed = 2 + } + break + case 'adc': { + const sum = registers[a] + registers[b] + carry + registers[a] = sum & 255 + carry = sum > 255 ? 1 : 0 + break + } + case 'rjmp': advance = byAddress.get(instruction.target) - pc; elapsed = 2; break + case 'nop': break + case 'ldi': registers[a] = Number(b); break + case 'ld': registers[a] = bytes[pointer++] ?? 0; elapsed = 2; break + case 'sbiw': registers[a]--; zero = registers[a] === 0; elapsed = 2; break + default: throw new Error(`Unmodeled AVR instruction ${instruction.op}`) + } + assert.ok(Number.isFinite(advance), `invalid branch target at ${instruction.address.toString(16)}`) + cycles += elapsed + pc += advance + } + assert.equal(level, 0, 'data pin must finish low') + assert.equal(rises.length, bytes.length * 8, 'one rising edge per data bit') + assert.equal(falls.length, rises.length, 'one falling edge per data bit') + for (let i = 0; i < rises.length; i++) { + const value = (bytes[i >> 3] >> (7 - (i & 7))) & 1 + assert.equal(rises[i] - rises[0], i * 20, `bit ${i} period`) + assert.equal(falls[i] - rises[i], value ? 13 : 5, `bit ${i} high width`) + } + assert.equal(pointer, bytes.length + 1, 'byte pointer must advance across the full frame') +} + +// Every byte value, transitions both ways, and full 100-pixel frame length. +for (let value = 0; value < 256; value++) verify([value, value ^ 255, value]) +verify(Array.from({ length: 300 }, (_, i) => i & 255)) +console.log('PASS: linked AVR strip loop: 256 byte patterns, byte boundaries, 300-byte frame; 20-cycle cells and 5/13-cycle high pulses.') diff --git a/docs/STRIP-STREAMING.md b/docs/STRIP-STREAMING.md index 6ca573f9..43b09eae 100644 --- a/docs/STRIP-STREAMING.md +++ b/docs/STRIP-STREAMING.md @@ -68,3 +68,25 @@ events remain concurrent because they do not write a host request. The 100-pixel limit is intentional on the AVR SRAM budget. Higher counts require a firmware target/profile with enough memory and a matching advertised limit; the UI and native encoder reject counts above this build's maximum. + +## Checking the compiled sender + +Acknowledged frames do not prove electrical output. The AVR assembly loop must +declare operands overwritten before the last input use as early-clobber outputs. +In particular, `next` starts equal to `lo`; without early-clobber, GCC can assign +both to the same register. The first one bit then replaces the constant low +value with high, and later writes cannot bring the data pin low. A memory clobber +alone does not prevent this register alias. The advancing frame pointer must +also be a read/write operand, and the buffer reads require a memory clobber. + +After compiling, exercise the actual linked instructions with: + +```console +node Tools/Firmware/verify-strip-waveform.mjs .build/firmware/PCController.ino_atmega328p_16000000L.lst +``` + +The firmware CI job runs this gate. It checks register independence, every byte +value, byte boundaries, and a 300-byte frame, including 20-cycle cells and the +existing 5/13-cycle high pulses. The test rejects unrecognized instruction +sequences instead of assuming their timing. Physical LED acceptance remains a +separate check using the host's `strip rainbow 100 20` command. From 0b52df0caf5bc19122be44984c30aed233829f02 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:54:22 +0330 Subject: [PATCH 02/29] fix: disable Urclock metadata for adapter firmware writes --- .../internal/programmer/libavrdude_adapter.go | 2 +- .../programmer/libavrdude_adapter_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Tools/Controller/internal/programmer/libavrdude_adapter.go b/Tools/Controller/internal/programmer/libavrdude_adapter.go index 20d5fe49..09b4cdc2 100644 --- a/Tools/Controller/internal/programmer/libavrdude_adapter.go +++ b/Tools/Controller/internal/programmer/libavrdude_adapter.go @@ -172,7 +172,7 @@ func libavrdudeAdapterArguments(options Options, configuration, programmer strin switch options.Operation { case OperationWriteFlash: if programmer == "urclock" { - args = append(args, "--no-auto-erase") + args = append(args, "--no-auto-erase", "--extended", "nometadata") } update = "flash:w:" + options.HexPath + ":i" case OperationReadFlash: diff --git a/Tools/Controller/internal/programmer/libavrdude_adapter_test.go b/Tools/Controller/internal/programmer/libavrdude_adapter_test.go index 969fe0f8..afd699d8 100644 --- a/Tools/Controller/internal/programmer/libavrdude_adapter_test.go +++ b/Tools/Controller/internal/programmer/libavrdude_adapter_test.go @@ -124,3 +124,22 @@ func TestNonDefaultAVRRequiresExplicitGeometry(t *testing.T) { t.Fatalf("unexpected non-default AVR command: %s", joined) } } + +// The firmware identity occupies the end of application flash. Urclock metadata +// must not reserve its final byte; this must match the CLI uploader contract. +func TestLibavrdudeUrclockFlashDisablesMetadata(t *testing.T) { + for _, programmer := range []string{"urclock", "usbasp"} { + args, err := libavrdudeAdapterArguments(Options{ + MCU: "atmega328p", Operation: OperationWriteFlash, + HexPath: "firmware.hex", BaudRate: 115200, + }, "avrdude.conf", programmer) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(args, " ") + got := strings.Contains(joined, "--extended nometadata") + if got != (programmer == "urclock") { + t.Fatalf("%s metadata policy: %s", programmer, joined) + } + } +} From 7374de431d9b1bd9f5850852a15cf9cb2edd7f77 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:59:35 +0330 Subject: [PATCH 03/29] fix: avoid extra idle time after late strip frames --- Tools/Controller/internal/control/strip.go | 4 +++- Tools/Controller/internal/control/strip_test.go | 13 ++++++++++--- Tools/Firmware/verify-strip-waveform.mjs | 3 +++ docs/STRIP-STREAMING.md | 8 ++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Tools/Controller/internal/control/strip.go b/Tools/Controller/internal/control/strip.go index 3ff5d5c6..e2fd00f6 100644 --- a/Tools/Controller/internal/control/strip.go +++ b/Tools/Controller/internal/control/strip.go @@ -298,7 +298,9 @@ func (outputs *OutputScheduler) streamStripRainbow(ctx context.Context, count, f func nextStripFrameTime(previous, now time.Time, period time.Duration) time.Time { next := previous.Add(period) if !next.After(now) { - next = next.Add((now.Sub(next)/period + 1) * period) + // A late transfer already consumed the frame budget. Render current + // animation time immediately and rebase, without a catch-up burst. + next = now } return next } diff --git a/Tools/Controller/internal/control/strip_test.go b/Tools/Controller/internal/control/strip_test.go index d6efdcab..5e6fb902 100644 --- a/Tools/Controller/internal/control/strip_test.go +++ b/Tools/Controller/internal/control/strip_test.go @@ -120,14 +120,21 @@ func TestStripStreamRecoversAfterTransientFrameTimeout(t *testing.T) { } } -func TestNextStripFrameTimeSkipsBufferedIntervals(t *testing.T) { +func TestNextStripFrameTimeRebasesLateTransfers(t *testing.T) { period := 50 * time.Millisecond previous := time.Unix(100, 0) if got, want := nextStripFrameTime(previous, previous.Add(10*time.Millisecond), period), previous.Add(period); !got.Equal(want) { t.Fatalf("ordinary next frame = %s, want %s", got, want) } - if got, want := nextStripFrameTime(previous, previous.Add(121*time.Millisecond), period), previous.Add(150*time.Millisecond); !got.Equal(want) { - t.Fatalf("delayed next frame = %s, want %s", got, want) + for _, delay := range []time.Duration{50, 70, 121} { + now := previous.Add(delay * time.Millisecond) + got := nextStripFrameTime(previous, now, period) + if !got.Equal(now) { + t.Fatalf("late transfer %s: next frame = %s, want %s", delay*time.Millisecond, got, now) + } + if following := nextStripFrameTime(got, now.Add(10*time.Millisecond), period); !following.Equal(now.Add(period)) { + t.Fatalf("late transfer must rebase subsequent pacing: got %s", following) + } } } diff --git a/Tools/Firmware/verify-strip-waveform.mjs b/Tools/Firmware/verify-strip-waveform.mjs index 9a1504f6..e67b4c32 100644 --- a/Tools/Firmware/verify-strip-waveform.mjs +++ b/Tools/Firmware/verify-strip-waveform.mjs @@ -3,6 +3,9 @@ // Exercise the actual linked AVR bit loop, including compiler register allocation. import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import { loadProjectEnv } from '../Build/env.mjs' + +loadProjectEnv() const listingPath = process.argv[2] if (!listingPath) throw new Error('Usage: node Tools/Firmware/verify-strip-waveform.mjs AVR-LISTING.lst') diff --git a/docs/STRIP-STREAMING.md b/docs/STRIP-STREAMING.md index 43b09eae..bdcefe52 100644 --- a/docs/STRIP-STREAMING.md +++ b/docs/STRIP-STREAMING.md @@ -90,3 +90,11 @@ value, byte boundaries, and a 300-byte frame, including 20-cycle cells and the existing 5/13-cycle high pulses. The test rejects unrecognized instruction sequences instead of assuming their timing. Physical LED acceptance remains a separate check using the host's `strip rainbow 100 20` command. + +The requested frame rate is a ceiling, not a delivery measurement. A 100-pixel +frame requires seven acknowledged data chunks plus a show request. On the tested +serial connection, complete frame commands took 66–76 ms, already exceeding a +20 FPS frame's 50 ms budget. When a transfer finishes late, the scheduler renders +the current animation time immediately and rebases its deadline; waiting for the +next original grid boundary would unnecessarily reduce this case to about 10 FPS. +Short transfers still wait for their deadline, and no stale frames are queued. From a48acbdf79ac0e409c19db2a78b78846bbb8fb27 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:11:04 +0330 Subject: [PATCH 04/29] fix: recover verified uploads after settings restoration fails --- .../cmd/controller/runtime_configuration.go | 31 ++++++++++--------- Tools/Controller/controller.go | 27 +++++++++------- Tools/Controller/controller_test.go | 8 ++--- Tools/Controller/host/config.go | 15 ++++----- Tools/Controller/host/config_test.go | 4 ++- Tools/Controller/internal/control/commands.go | 7 ++++- .../internal/control/programming_lifecycle.go | 6 +++- .../control/programming_lifecycle_test.go | 30 ++++++++++++++++++ 8 files changed, 89 insertions(+), 39 deletions(-) diff --git a/Tools/Controller/cmd/controller/runtime_configuration.go b/Tools/Controller/cmd/controller/runtime_configuration.go index 250a8941..b493ad14 100644 --- a/Tools/Controller/cmd/controller/runtime_configuration.go +++ b/Tools/Controller/cmd/controller/runtime_configuration.go @@ -294,8 +294,9 @@ func commandOptions(store *appconfig.Store, fallbackProject string) control.Comm ArduinoConfig: config.Programming.ToolchainConfig, Avrdude: config.Programming.Avrdude, AvrdudeConf: config.Programming.AvrdudeConf, - Programmer: configuredProgrammer(config), - HostConfig: store.Current, + AvrdudeBackend: config.Programming.AvrdudeBackend, AvrdudeAdapter: config.Programming.AvrdudeAdapter, + Programmer: configuredProgrammer(config), + HostConfig: store.Current, BuzzerRuntime: func(settings native.Settings, haveBoard bool) appconfig.BuzzerRuntimeStatus { state := store.BuzzerRuntimeState() var backend, executable, backendError string @@ -352,14 +353,15 @@ func commandOptions(store *appconfig.Store, fallbackProject string) control.Comm ArduinoConfig: current.Programming.ToolchainConfig, Avrdude: current.Programming.Avrdude, AvrdudeConf: current.Programming.AvrdudeConf, - Programmer: configuredProgrammer(current), - HostConfig: store.Current, - BuzzerRuntime: options.BuzzerRuntime, - UpdateHostConfig: options.UpdateHostConfig, - Macros: options.Macros, - InitializeBoard: options.InitializeBoard, - BlankBoard: options.BlankBoard, - USBaspDriver: options.USBaspDriver, + AvrdudeBackend: current.Programming.AvrdudeBackend, AvrdudeAdapter: current.Programming.AvrdudeAdapter, + Programmer: configuredProgrammer(current), + HostConfig: store.Current, + BuzzerRuntime: options.BuzzerRuntime, + UpdateHostConfig: options.UpdateHostConfig, + Macros: options.Macros, + InitializeBoard: options.InitializeBoard, + BlankBoard: options.BlankBoard, + USBaspDriver: options.USBaspDriver, } } return options @@ -517,10 +519,11 @@ func apiOptions( ToolchainConfig: config.Programming.ToolchainConfig, Avrdude: config.Programming.Avrdude, AvrdudeConf: config.Programming.AvrdudeConf, - Programmer: configuredProgrammer(config), - MotionDoorPolicy: config.Safety.MotionDoorPolicy, - RF: config.RF, - OSActions: config.OSActions, + AvrdudeBackend: config.Programming.AvrdudeBackend, AvrdudeAdapter: config.Programming.AvrdudeAdapter, + Programmer: configuredProgrammer(config), + MotionDoorPolicy: config.Safety.MotionDoorPolicy, + RF: config.RF, + OSActions: config.OSActions, LCDPresentation: controllerapi.LCDPresentationOptions{ Enabled: config.UI.LCDServiceEnabled, Debounce: time.Duration(config.UI.LCDPromptDebounceMS) * time.Millisecond, diff --git a/Tools/Controller/controller.go b/Tools/Controller/controller.go index cf1a7272..47166de3 100644 --- a/Tools/Controller/controller.go +++ b/Tools/Controller/controller.go @@ -258,6 +258,8 @@ type Options struct { ToolchainConfig string `json:"toolchain_config,omitempty"` Avrdude string `json:"avrdude,omitempty"` AvrdudeConf string `json:"avrdude_conf,omitempty"` + AvrdudeBackend string `json:"avrdude_backend,omitempty"` + AvrdudeAdapter string `json:"avrdude_adapter,omitempty"` Programmer string `json:"programmer,omitempty"` Macros []Macro `json:"macros,omitempty"` StripEffects []StripEffect `json:"strip_effects,omitempty"` @@ -717,10 +719,11 @@ func New(options Options) *Client { ArduinoConfig: options.ToolchainConfig, Avrdude: options.Avrdude, AvrdudeConf: options.AvrdudeConf, - Programmer: options.Programmer, - HostConfig: client.currentHostConfig, - UpdateHostConfig: client.updateHostConfig, - Outputs: client.outputs, + AvrdudeBackend: options.AvrdudeBackend, AvrdudeAdapter: options.AvrdudeAdapter, + Programmer: options.Programmer, + HostConfig: client.currentHostConfig, + UpdateHostConfig: client.updateHostConfig, + Outputs: client.outputs, } client.engine = control.NewCommandEngine(runtime, control.CommandOptions{ ProjectPath: options.ProjectPath, @@ -732,11 +735,12 @@ func New(options Options) *Client { ArduinoConfig: options.ToolchainConfig, Avrdude: options.Avrdude, AvrdudeConf: options.AvrdudeConf, - Programmer: options.Programmer, - HostConfig: client.currentHostConfig, - UpdateHostConfig: client.updateHostConfig, - Resolve: client.currentCommandOptions, - Outputs: client.outputs, + AvrdudeBackend: options.AvrdudeBackend, AvrdudeAdapter: options.AvrdudeAdapter, + Programmer: options.Programmer, + HostConfig: client.currentHostConfig, + UpdateHostConfig: client.updateHostConfig, + Resolve: client.currentCommandOptions, + Outputs: client.outputs, }) automationContext, cancelAutomations := context.WithCancel(context.Background()) go func() { @@ -826,8 +830,9 @@ func (client *Client) ApplyHostOptions(options Options) bool { FirmwareFeatures: firmwareFeatures, FirmwareFeaturesError: firmwareFeaturesErr, ArduinoCLI: options.ToolchainCLI, Avrdude: options.Avrdude, - ArduinoConfig: options.ToolchainConfig, - AvrdudeConf: options.AvrdudeConf, + ArduinoConfig: options.ToolchainConfig, + AvrdudeConf: options.AvrdudeConf, + AvrdudeBackend: options.AvrdudeBackend, AvrdudeAdapter: options.AvrdudeAdapter, Programmer: options.Programmer, HostConfig: client.currentHostConfig, UpdateHostConfig: client.updateHostConfig, diff --git a/Tools/Controller/controller_test.go b/Tools/Controller/controller_test.go index fb81e357..00754016 100644 --- a/Tools/Controller/controller_test.go +++ b/Tools/Controller/controller_test.go @@ -94,15 +94,15 @@ func TestPublicOptionsExposeCanonicalFirmwareFeatureStatus(t *testing.T) { } func TestToolchainConfigurationSurvivesPublicFacadeUpdates(t *testing.T) { - client := New(Options{ToolchainCLI: "cli", ToolchainConfig: "managed.yaml", Avrdude: "avr", AvrdudeConf: "avr.conf"}) + client := New(Options{ToolchainCLI: "cli", ToolchainConfig: "managed.yaml", Avrdude: "avr", AvrdudeConf: "avr.conf", AvrdudeBackend: "cli", AvrdudeAdapter: "adapter"}) defer client.Shutdown() options := client.currentCommandOptions() - if options.ArduinoConfig != "managed.yaml" || options.ArduinoCLI != "cli" || options.Avrdude != "avr" || options.AvrdudeConf != "avr.conf" { + if options.ArduinoConfig != "managed.yaml" || options.ArduinoCLI != "cli" || options.Avrdude != "avr" || options.AvrdudeConf != "avr.conf" || options.AvrdudeBackend != "cli" || options.AvrdudeAdapter != "adapter" { t.Fatalf("initial programming paths lost: %#v", options) } - client.ApplyHostOptions(Options{ToolchainCLI: "updated-cli", ToolchainConfig: "updated.yaml", Avrdude: "updated-avr", AvrdudeConf: "updated.conf"}) + client.ApplyHostOptions(Options{ToolchainCLI: "updated-cli", ToolchainConfig: "updated.yaml", Avrdude: "updated-avr", AvrdudeConf: "updated.conf", AvrdudeBackend: "library", AvrdudeAdapter: "updated-adapter"}) options = client.currentCommandOptions() - if options.ArduinoConfig != "updated.yaml" || options.ArduinoCLI != "updated-cli" || options.Avrdude != "updated-avr" || options.AvrdudeConf != "updated.conf" { + if options.ArduinoConfig != "updated.yaml" || options.ArduinoCLI != "updated-cli" || options.Avrdude != "updated-avr" || options.AvrdudeConf != "updated.conf" || options.AvrdudeBackend != "library" || options.AvrdudeAdapter != "updated-adapter" { t.Fatalf("updated programming paths lost: %#v", options) } } diff --git a/Tools/Controller/host/config.go b/Tools/Controller/host/config.go index b32743b1..490119b6 100644 --- a/Tools/Controller/host/config.go +++ b/Tools/Controller/host/config.go @@ -39,13 +39,14 @@ func controllerOptionsFromConfig( ToolchainConfig: config.Programming.ToolchainConfig, Avrdude: config.Programming.Avrdude, AvrdudeConf: config.Programming.AvrdudeConf, - Programmer: config.Programming.Programmer, - Macros: publicMacros(config.Macros), - Melodies: append([]controller.Melody(nil), config.Melodies...), - StatusEffects: append([]controller.StatusLEDEffect(nil), config.StatusEffects...), - Scripts: cloneStrings(config.Scripts), - Automations: publicAutomations(config.Automations), - MotionDoorPolicy: config.Safety.MotionDoorPolicy, + AvrdudeBackend: config.Programming.AvrdudeBackend, AvrdudeAdapter: config.Programming.AvrdudeAdapter, + Programmer: config.Programming.Programmer, + Macros: publicMacros(config.Macros), + Melodies: append([]controller.Melody(nil), config.Melodies...), + StatusEffects: append([]controller.StatusLEDEffect(nil), config.StatusEffects...), + Scripts: cloneStrings(config.Scripts), + Automations: publicAutomations(config.Automations), + MotionDoorPolicy: config.Safety.MotionDoorPolicy, LCDPresentation: controller.LCDPresentationOptions{ Enabled: config.UI.LCDServiceEnabled, Debounce: time.Duration(config.UI.LCDPromptDebounceMS) * time.Millisecond, diff --git a/Tools/Controller/host/config_test.go b/Tools/Controller/host/config_test.go index 9d76727a..e4986f07 100644 --- a/Tools/Controller/host/config_test.go +++ b/Tools/Controller/host/config_test.go @@ -11,8 +11,10 @@ func TestControllerConfigRetainsToolchainConfiguration(t *testing.T) { config.Programming.ToolchainConfig = "selected-data.yaml" config.Programming.Avrdude = "avr" config.Programming.AvrdudeConf = "avr.conf" + config.Programming.AvrdudeBackend = "cli" + config.Programming.AvrdudeAdapter = "adapter" got, err := controllerOptionsFromConfig(config, "config.json") - if err != nil || got.ToolchainConfig != "selected-data.yaml" || got.ToolchainCLI != "cli" || got.Avrdude != "avr" || got.AvrdudeConf != "avr.conf" { + if err != nil || got.ToolchainConfig != "selected-data.yaml" || got.ToolchainCLI != "cli" || got.Avrdude != "avr" || got.AvrdudeConf != "avr.conf" || got.AvrdudeBackend != "cli" || got.AvrdudeAdapter != "adapter" { t.Fatalf("configured paths lost: %#v %v", got, err) } } diff --git a/Tools/Controller/internal/control/commands.go b/Tools/Controller/internal/control/commands.go index 9181350e..8303d1dd 100644 --- a/Tools/Controller/internal/control/commands.go +++ b/Tools/Controller/internal/control/commands.go @@ -50,6 +50,8 @@ type CommandOptions struct { ArduinoConfig string Avrdude string AvrdudeConf string + AvrdudeBackend string + AvrdudeAdapter string Programmer string HostConfig func() appconfig.Config BuzzerRuntime func(native.Settings, bool) appconfig.BuzzerRuntimeStatus @@ -4298,6 +4300,7 @@ func programCommandRun( Method: method, Operation: operation, FQBN: options.FQBN, ArduinoCLI: options.ArduinoCLI, ArduinoConfig: options.ArduinoConfig, Avrdude: options.Avrdude, AvrdudeConf: options.AvrdudeConf, + AvrdudeBackend: options.AvrdudeBackend, AvrdudeAdapter: options.AvrdudeAdapter, Programmer: options.Programmer, USBaspAutoSlow: true, } @@ -4720,7 +4723,8 @@ func safeFlashCommand( Method: method, Port: port, Operation: programmer.OperationBackup, FQBN: options.FQBN, Programmer: options.Programmer, ArduinoCLI: options.ArduinoCLI, ArduinoConfig: options.ArduinoConfig, Avrdude: options.Avrdude, - AvrdudeConf: options.AvrdudeConf, + AvrdudeConf: options.AvrdudeConf, + AvrdudeBackend: options.AvrdudeBackend, AvrdudeAdapter: options.AvrdudeAdapter, USBaspAutoSlow: true, ApplicationHash: snapshot.Hello.BuildHash, ApplicationIdentitySchema: snapshot.Hello.IdentitySchema, @@ -5019,6 +5023,7 @@ func recoverProgrammingCommand( HexPath: firmwarePath, FQBN: options.FQBN, Programmer: options.Programmer, ArduinoCLI: options.ArduinoCLI, ArduinoConfig: options.ArduinoConfig, Avrdude: options.Avrdude, AvrdudeConf: options.AvrdudeConf, + AvrdudeBackend: options.AvrdudeBackend, AvrdudeAdapter: options.AvrdudeAdapter, USBaspAutoSlow: true, } verifyErr := programmer.VerifyFlashReadbackWithRunner( diff --git a/Tools/Controller/internal/control/programming_lifecycle.go b/Tools/Controller/internal/control/programming_lifecycle.go index 761498ef..23360164 100644 --- a/Tools/Controller/internal/control/programming_lifecycle.go +++ b/Tools/Controller/internal/control/programming_lifecycle.go @@ -1174,7 +1174,11 @@ func findFailedProgrammingSession( return session, err } targetSHA256 = strings.ToLower(strings.TrimSpace(targetSHA256)) - matching := session.HostResult == "failed" && session.SafeStateApplied && + // A verified write can still lose the connection while restoring settings. + // Recovery performs fresh flash readback before attempting restoration again. + recoverable := session.HostResult == "failed" || + (session.HostResult == "succeeded" && session.Phase == "restore-failed-safe") + matching := recoverable && session.SafeStateApplied && strings.EqualFold(session.TargetFirmwareSHA256, targetSHA256) if !matching { return nil, fmt.Errorf( diff --git a/Tools/Controller/internal/control/programming_lifecycle_test.go b/Tools/Controller/internal/control/programming_lifecycle_test.go index 67f93b01..42b3d72d 100644 --- a/Tools/Controller/internal/control/programming_lifecycle_test.go +++ b/Tools/Controller/internal/control/programming_lifecycle_test.go @@ -880,6 +880,36 @@ func TestFindRetryableProgrammingSessionUsesNewestMarkerBeforeOlderConflicts(t * } } +func TestProgrammingRecoveryAcceptsVerifiedWriteWithFailedRestore(t *testing.T) { + paths, firmware := programmingLifecycleFixture(t) + document, err := programmer.LoadIntelHex(firmware) + if err != nil { + t.Fatal(err) + } + identity := programmingIdentity(connectedProgrammingSnapshot(0).Port) + session := &ProgrammingSession{ + Format: programmingMarkerFormat, PreparedAt: time.Now().UTC(), + Device: identity, TargetFirmwareSHA256: document.SourceSHA256, + SafeStateApplied: true, Phase: "restore-failed-safe", HostResult: "succeeded", + } + if _, err := persistProgrammingMarker(paths, session); err != nil { + t.Fatal(err) + } + if got, err := findFailedProgrammingSession(paths, identity, document.SourceSHA256); err != nil || got == nil { + t.Fatalf("verified write with failed restore must permit fresh-readback recovery: %v", err) + } + if _, err := findFailedProgrammingSession(paths, identity, strings.Repeat("0", 64)); err == nil { + t.Fatal("recovery accepted a different image") + } + session.Phase = "restored" + if _, err := persistProgrammingMarker(paths, session); err != nil { + t.Fatal(err) + } + if _, err := findFailedProgrammingSession(paths, identity, document.SourceSHA256); err == nil { + t.Fatal("completed success was treated as failed restoration") + } +} + func TestProgrammingLifecycleFailedProgrammerResultRetainsLatchAndMarker(t *testing.T) { paths, firmware := programmingLifecycleFixture(t) device := &fakeProgrammingDevice{ From d14d5063ebf75349e9d264ea64bbed1240c35d89 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:02:11 +0330 Subject: [PATCH 05/29] fix: decode RF frames outside the UART-blocking interrupt --- .github/workflows/firmware.yml | 3 + BoardSupportSources.cpp | 3 + LocalLib/RCSwitch.cpp | 728 +++++++++++++++++++++ LocalLib/RCSwitch.h | 186 ++++++ PCController.ino | 4 + Tools/Firmware/tests/rf-deferred/Arduino.h | 23 + Tools/Firmware/tests/rf-deferred/main.cpp | 33 + Tools/Firmware/verify-rf-deferred.mjs | 14 + docs/RF-DEFERRED-DECODE.md | 30 + 9 files changed, 1024 insertions(+) create mode 100644 LocalLib/RCSwitch.cpp create mode 100644 LocalLib/RCSwitch.h create mode 100644 Tools/Firmware/tests/rf-deferred/Arduino.h create mode 100644 Tools/Firmware/tests/rf-deferred/main.cpp create mode 100644 Tools/Firmware/verify-rf-deferred.mjs create mode 100644 docs/RF-DEFERRED-DECODE.md diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index d3615966..ebfb253e 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -101,6 +101,9 @@ jobs: --clean --version "$PCCONTROLLER_VERSION" + - name: "Verify RF decoding stays outside the interrupt handler" + run: node Tools/Firmware/verify-rf-deferred.mjs + - name: "Verify linked strip output waveform" run: >- node Tools/Firmware/verify-strip-waveform.mjs diff --git a/BoardSupportSources.cpp b/BoardSupportSources.cpp index 51209f47..27d5a401 100644 --- a/BoardSupportSources.cpp +++ b/BoardSupportSources.cpp @@ -2,6 +2,9 @@ // Keep the LocalLib domain layout while building each implementation once. #include "ProjectConfig.h" +#if !defined(PCCONTROLLER_NATIVE) +#include "LocalLib/RCSwitch.cpp" +#endif #include "LocalLib/DallasTemperatureBus.cpp" #include "LocalLib/I2cLcd.cpp" #include "LocalLib/Keys.cpp" diff --git a/LocalLib/RCSwitch.cpp b/LocalLib/RCSwitch.cpp new file mode 100644 index 00000000..768dbb1c --- /dev/null +++ b/LocalLib/RCSwitch.cpp @@ -0,0 +1,728 @@ +// PCController: rc-switch 2.6.4, LGPL-2.1-or-later. Decode outside the ISR. +/* + RCSwitch - Arduino libary for remote control outlet switches + Copyright (c) 2011 Suat Özgür. All right reserved. + + Contributors: + - Andre Koehler / info(at)tomate-online(dot)de + - Gordeev Andrey Vladimirovich / gordeev(at)openpyro(dot)com + - Skineffect / http://forum.ardumote.com/viewtopic.php?f=2&t=46 + - Dominik Fischer / dom_fischer(at)web(dot)de + - Frank Oltmanns / .(at)gmail(dot)com + - Andreas Steinel / A.(at)gmail(dot)com + - Max Horn / max(at)quendi(dot)de + - Robert ter Vehn / .(at)gmail(dot)com + - Johann Richard / .(at)gmail(dot)com + - Vlad Gheorghe / .(at)gmail(dot)com https://github.com/vgheo + - Matias Cuenca-Acuna + + Project home: https://github.com/sui77/rc-switch/ + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "RCSwitch.h" + +#ifdef RaspberryPi + // PROGMEM and _P functions are for AVR based microprocessors, + // so we must normalize these for the ARM processor: + #define PROGMEM + #define memcpy_P(dest, src, num) memcpy((dest), (src), (num)) +#endif + +#if defined(ESP8266) + // interrupt handler and related code must be in RAM on ESP8266, + // according to issue #46. + #define RECEIVE_ATTR ICACHE_RAM_ATTR + #define VAR_ISR_ATTR +#elif defined(ESP32) + #define RECEIVE_ATTR IRAM_ATTR + #define VAR_ISR_ATTR DRAM_ATTR +#else + #define RECEIVE_ATTR + #define VAR_ISR_ATTR +#endif + + +/* Format for protocol definitions: + * {pulselength, Sync bit, "0" bit, "1" bit, invertedSignal} + * + * pulselength: pulse length in microseconds, e.g. 350 + * Sync bit: {1, 31} means 1 high pulse and 31 low pulses + * (perceived as a 31*pulselength long pulse, total length of sync bit is + * 32*pulselength microseconds), i.e: + * _ + * | |_______________________________ (don't count the vertical bars) + * "0" bit: waveform for a data bit of value "0", {1, 3} means 1 high pulse + * and 3 low pulses, total length (1+3)*pulselength, i.e: + * _ + * | |___ + * "1" bit: waveform for a data bit of value "1", e.g. {3,1}: + * ___ + * | |_ + * + * These are combined to form Tri-State bits when sending or receiving codes. + */ +#if defined(ESP8266) || defined(ESP32) +static const VAR_ISR_ATTR RCSwitch::Protocol proto[] = { +#else +static const RCSwitch::Protocol PROGMEM proto[] = { +#endif + { 350, { 1, 31 }, { 1, 3 }, { 3, 1 }, false }, // protocol 1 + { 650, { 1, 10 }, { 1, 2 }, { 2, 1 }, false }, // protocol 2 + { 100, { 30, 71 }, { 4, 11 }, { 9, 6 }, false }, // protocol 3 + { 380, { 1, 6 }, { 1, 3 }, { 3, 1 }, false }, // protocol 4 + { 500, { 6, 14 }, { 1, 2 }, { 2, 1 }, false }, // protocol 5 + { 450, { 23, 1 }, { 1, 2 }, { 2, 1 }, true }, // protocol 6 (HT6P20B) + { 150, { 2, 62 }, { 1, 6 }, { 6, 1 }, false }, // protocol 7 (HS2303-PT, i. e. used in AUKEY Remote) + { 200, { 3, 130}, { 7, 16 }, { 3, 16}, false}, // protocol 8 Conrad RS-200 RX + { 200, { 130, 7 }, { 16, 7 }, { 16, 3 }, true}, // protocol 9 Conrad RS-200 TX + { 365, { 18, 1 }, { 3, 1 }, { 1, 3 }, true }, // protocol 10 (1ByOne Doorbell) + { 270, { 36, 1 }, { 1, 2 }, { 2, 1 }, true }, // protocol 11 (HT12E) + { 320, { 36, 1 }, { 1, 2 }, { 2, 1 }, true } // protocol 12 (SM5212) +}; + +enum { + numProto = sizeof(proto) / sizeof(proto[0]) +}; + +#if not defined( RCSwitchDisableReceiving ) +volatile unsigned long RCSwitch::nReceivedValue = 0; +volatile unsigned int RCSwitch::nReceivedBitlength = 0; +volatile unsigned int RCSwitch::nReceivedDelay = 0; +volatile unsigned int RCSwitch::nReceivedProtocol = 0; +int RCSwitch::nReceiveTolerance = 60; +const unsigned int RCSwitch::nSeparationLimit = 4300; +// separationLimit: minimum microseconds between received codes, closer codes are ignored. +// according to discussion on issue #14 it might be more suitable to set the separation +// limit to the same time as the 'low' part of the sync signal for the current protocol. +unsigned int RCSwitch::timings[RCSWITCH_MAX_CHANGES]; +#endif + +RCSwitch::RCSwitch() { + this->nTransmitterPin = -1; + this->setRepeatTransmit(10); + this->setProtocol(1); + #if not defined( RCSwitchDisableReceiving ) + this->nReceiverInterrupt = -1; + this->setReceiveTolerance(60); + RCSwitch::nReceivedValue = 0; + #endif +} + +/** + * Sets the protocol to send. + */ +void RCSwitch::setProtocol(Protocol protocol) { + this->protocol = protocol; +} + +/** + * Sets the protocol to send, from a list of predefined protocols + */ +void RCSwitch::setProtocol(int nProtocol) { + if (nProtocol < 1 || nProtocol > numProto) { + nProtocol = 1; // TODO: trigger an error, e.g. "bad protocol" ??? + } +#if defined(ESP8266) || defined(ESP32) + this->protocol = proto[nProtocol-1]; +#else + memcpy_P(&this->protocol, &proto[nProtocol-1], sizeof(Protocol)); +#endif +} + +/** + * Sets the protocol to send with pulse length in microseconds. + */ +void RCSwitch::setProtocol(int nProtocol, int nPulseLength) { + setProtocol(nProtocol); + this->setPulseLength(nPulseLength); +} + + +/** + * Sets pulse length in microseconds + */ +void RCSwitch::setPulseLength(int nPulseLength) { + this->protocol.pulseLength = nPulseLength; +} + +/** + * Sets Repeat Transmits + */ +void RCSwitch::setRepeatTransmit(int nRepeatTransmit) { + this->nRepeatTransmit = nRepeatTransmit; +} + +/** + * Set Receiving Tolerance + */ +#if not defined( RCSwitchDisableReceiving ) +void RCSwitch::setReceiveTolerance(int nPercent) { + RCSwitch::nReceiveTolerance = nPercent; +} +#endif + + +/** + * Enable transmissions + * + * @param nTransmitterPin Arduino Pin to which the sender is connected to + */ +void RCSwitch::enableTransmit(int nTransmitterPin) { + this->nTransmitterPin = nTransmitterPin; + pinMode(this->nTransmitterPin, OUTPUT); +} + +/** + * Disable transmissions + */ +void RCSwitch::disableTransmit() { + this->nTransmitterPin = -1; +} + +/** + * Switch a remote switch on (Type D REV) + * + * @param sGroup Code of the switch group (A,B,C,D) + * @param nDevice Number of the switch itself (1..3) + */ +void RCSwitch::switchOn(char sGroup, int nDevice) { + this->sendTriState( this->getCodeWordD(sGroup, nDevice, true) ); +} + +/** + * Switch a remote switch off (Type D REV) + * + * @param sGroup Code of the switch group (A,B,C,D) + * @param nDevice Number of the switch itself (1..3) + */ +void RCSwitch::switchOff(char sGroup, int nDevice) { + this->sendTriState( this->getCodeWordD(sGroup, nDevice, false) ); +} + +/** + * Switch a remote switch on (Type C Intertechno) + * + * @param sFamily Familycode (a..f) + * @param nGroup Number of group (1..4) + * @param nDevice Number of device (1..4) + */ +void RCSwitch::switchOn(char sFamily, int nGroup, int nDevice) { + this->sendTriState( this->getCodeWordC(sFamily, nGroup, nDevice, true) ); +} + +/** + * Switch a remote switch off (Type C Intertechno) + * + * @param sFamily Familycode (a..f) + * @param nGroup Number of group (1..4) + * @param nDevice Number of device (1..4) + */ +void RCSwitch::switchOff(char sFamily, int nGroup, int nDevice) { + this->sendTriState( this->getCodeWordC(sFamily, nGroup, nDevice, false) ); +} + +/** + * Switch a remote switch on (Type B with two rotary/sliding switches) + * + * @param nAddressCode Number of the switch group (1..4) + * @param nChannelCode Number of the switch itself (1..4) + */ +void RCSwitch::switchOn(int nAddressCode, int nChannelCode) { + this->sendTriState( this->getCodeWordB(nAddressCode, nChannelCode, true) ); +} + +/** + * Switch a remote switch off (Type B with two rotary/sliding switches) + * + * @param nAddressCode Number of the switch group (1..4) + * @param nChannelCode Number of the switch itself (1..4) + */ +void RCSwitch::switchOff(int nAddressCode, int nChannelCode) { + this->sendTriState( this->getCodeWordB(nAddressCode, nChannelCode, false) ); +} + +/** + * Deprecated, use switchOn(const char* sGroup, const char* sDevice) instead! + * Switch a remote switch on (Type A with 10 pole DIP switches) + * + * @param sGroup Code of the switch group (refers to DIP switches 1..5 where "1" = on and "0" = off, if all DIP switches are on it's "11111") + * @param nChannelCode Number of the switch itself (1..5) + */ +void RCSwitch::switchOn(const char* sGroup, int nChannel) { + const char* code[6] = { "00000", "10000", "01000", "00100", "00010", "00001" }; + this->switchOn(sGroup, code[nChannel]); +} + +/** + * Deprecated, use switchOff(const char* sGroup, const char* sDevice) instead! + * Switch a remote switch off (Type A with 10 pole DIP switches) + * + * @param sGroup Code of the switch group (refers to DIP switches 1..5 where "1" = on and "0" = off, if all DIP switches are on it's "11111") + * @param nChannelCode Number of the switch itself (1..5) + */ +void RCSwitch::switchOff(const char* sGroup, int nChannel) { + const char* code[6] = { "00000", "10000", "01000", "00100", "00010", "00001" }; + this->switchOff(sGroup, code[nChannel]); +} + +/** + * Switch a remote switch on (Type A with 10 pole DIP switches) + * + * @param sGroup Code of the switch group (refers to DIP switches 1..5 where "1" = on and "0" = off, if all DIP switches are on it's "11111") + * @param sDevice Code of the switch device (refers to DIP switches 6..10 (A..E) where "1" = on and "0" = off, if all DIP switches are on it's "11111") + */ +void RCSwitch::switchOn(const char* sGroup, const char* sDevice) { + this->sendTriState( this->getCodeWordA(sGroup, sDevice, true) ); +} + +/** + * Switch a remote switch off (Type A with 10 pole DIP switches) + * + * @param sGroup Code of the switch group (refers to DIP switches 1..5 where "1" = on and "0" = off, if all DIP switches are on it's "11111") + * @param sDevice Code of the switch device (refers to DIP switches 6..10 (A..E) where "1" = on and "0" = off, if all DIP switches are on it's "11111") + */ +void RCSwitch::switchOff(const char* sGroup, const char* sDevice) { + this->sendTriState( this->getCodeWordA(sGroup, sDevice, false) ); +} + + +/** + * Returns a char[13], representing the code word to be send. + * + */ +char* RCSwitch::getCodeWordA(const char* sGroup, const char* sDevice, bool bStatus) { + static char sReturn[13]; + int nReturnPos = 0; + + for (int i = 0; i < 5; i++) { + sReturn[nReturnPos++] = (sGroup[i] == '0') ? 'F' : '0'; + } + + for (int i = 0; i < 5; i++) { + sReturn[nReturnPos++] = (sDevice[i] == '0') ? 'F' : '0'; + } + + sReturn[nReturnPos++] = bStatus ? '0' : 'F'; + sReturn[nReturnPos++] = bStatus ? 'F' : '0'; + + sReturn[nReturnPos] = '\0'; + return sReturn; +} + +/** + * Encoding for type B switches with two rotary/sliding switches. + * + * The code word is a tristate word and with following bit pattern: + * + * +-----------------------------+-----------------------------+----------+------------+ + * | 4 bits address | 4 bits address | 3 bits | 1 bit | + * | switch group | switch number | not used | on / off | + * | 1=0FFF 2=F0FF 3=FF0F 4=FFF0 | 1=0FFF 2=F0FF 3=FF0F 4=FFF0 | FFF | on=F off=0 | + * +-----------------------------+-----------------------------+----------+------------+ + * + * @param nAddressCode Number of the switch group (1..4) + * @param nChannelCode Number of the switch itself (1..4) + * @param bStatus Whether to switch on (true) or off (false) + * + * @return char[13], representing a tristate code word of length 12 + */ +char* RCSwitch::getCodeWordB(int nAddressCode, int nChannelCode, bool bStatus) { + static char sReturn[13]; + int nReturnPos = 0; + + if (nAddressCode < 1 || nAddressCode > 4 || nChannelCode < 1 || nChannelCode > 4) { + return 0; + } + + for (int i = 1; i <= 4; i++) { + sReturn[nReturnPos++] = (nAddressCode == i) ? '0' : 'F'; + } + + for (int i = 1; i <= 4; i++) { + sReturn[nReturnPos++] = (nChannelCode == i) ? '0' : 'F'; + } + + sReturn[nReturnPos++] = 'F'; + sReturn[nReturnPos++] = 'F'; + sReturn[nReturnPos++] = 'F'; + + sReturn[nReturnPos++] = bStatus ? 'F' : '0'; + + sReturn[nReturnPos] = '\0'; + return sReturn; +} + +/** + * Like getCodeWord (Type C = Intertechno) + */ +char* RCSwitch::getCodeWordC(char sFamily, int nGroup, int nDevice, bool bStatus) { + static char sReturn[13]; + int nReturnPos = 0; + + int nFamily = (int)sFamily - 'a'; + if ( nFamily < 0 || nFamily > 15 || nGroup < 1 || nGroup > 4 || nDevice < 1 || nDevice > 4) { + return 0; + } + + // encode the family into four bits + sReturn[nReturnPos++] = (nFamily & 1) ? 'F' : '0'; + sReturn[nReturnPos++] = (nFamily & 2) ? 'F' : '0'; + sReturn[nReturnPos++] = (nFamily & 4) ? 'F' : '0'; + sReturn[nReturnPos++] = (nFamily & 8) ? 'F' : '0'; + + // encode the device and group + sReturn[nReturnPos++] = ((nDevice-1) & 1) ? 'F' : '0'; + sReturn[nReturnPos++] = ((nDevice-1) & 2) ? 'F' : '0'; + sReturn[nReturnPos++] = ((nGroup-1) & 1) ? 'F' : '0'; + sReturn[nReturnPos++] = ((nGroup-1) & 2) ? 'F' : '0'; + + // encode the status code + sReturn[nReturnPos++] = '0'; + sReturn[nReturnPos++] = 'F'; + sReturn[nReturnPos++] = 'F'; + sReturn[nReturnPos++] = bStatus ? 'F' : '0'; + + sReturn[nReturnPos] = '\0'; + return sReturn; +} + +/** + * Encoding for the REV Switch Type + * + * The code word is a tristate word and with following bit pattern: + * + * +-----------------------------+-------------------+----------+--------------+ + * | 4 bits address | 3 bits address | 3 bits | 2 bits | + * | switch group | device number | not used | on / off | + * | A=1FFF B=F1FF C=FF1F D=FFF1 | 1=0FF 2=F0F 3=FF0 | 000 | on=10 off=01 | + * +-----------------------------+-------------------+----------+--------------+ + * + * Source: http://www.the-intruder.net/funksteckdosen-von-rev-uber-arduino-ansteuern/ + * + * @param sGroup Name of the switch group (A..D, resp. a..d) + * @param nDevice Number of the switch itself (1..3) + * @param bStatus Whether to switch on (true) or off (false) + * + * @return char[13], representing a tristate code word of length 12 + */ +char* RCSwitch::getCodeWordD(char sGroup, int nDevice, bool bStatus) { + static char sReturn[13]; + int nReturnPos = 0; + + // sGroup must be one of the letters in "abcdABCD" + int nGroup = (sGroup >= 'a') ? (int)sGroup - 'a' : (int)sGroup - 'A'; + if ( nGroup < 0 || nGroup > 3 || nDevice < 1 || nDevice > 3) { + return 0; + } + + for (int i = 0; i < 4; i++) { + sReturn[nReturnPos++] = (nGroup == i) ? '1' : 'F'; + } + + for (int i = 1; i <= 3; i++) { + sReturn[nReturnPos++] = (nDevice == i) ? '1' : 'F'; + } + + sReturn[nReturnPos++] = '0'; + sReturn[nReturnPos++] = '0'; + sReturn[nReturnPos++] = '0'; + + sReturn[nReturnPos++] = bStatus ? '1' : '0'; + sReturn[nReturnPos++] = bStatus ? '0' : '1'; + + sReturn[nReturnPos] = '\0'; + return sReturn; +} + +/** + * @param sCodeWord a tristate code word consisting of the letter 0, 1, F + */ +void RCSwitch::sendTriState(const char* sCodeWord) { + // turn the tristate code word into the corresponding bit pattern, then send it + unsigned long code = 0; + unsigned int length = 0; + for (const char* p = sCodeWord; *p; p++) { + code <<= 2L; + switch (*p) { + case '0': + // bit pattern 00 + break; + case 'F': + // bit pattern 01 + code |= 1L; + break; + case '1': + // bit pattern 11 + code |= 3L; + break; + } + length += 2; + } + this->send(code, length); +} + +/** + * @param sCodeWord a binary code word consisting of the letter 0, 1 + */ +void RCSwitch::send(const char* sCodeWord) { + // turn the tristate code word into the corresponding bit pattern, then send it + unsigned long code = 0; + unsigned int length = 0; + for (const char* p = sCodeWord; *p; p++) { + code <<= 1L; + if (*p != '0') + code |= 1L; + length++; + } + this->send(code, length); +} + +/** + * Transmit the first 'length' bits of the integer 'code'. The + * bits are sent from MSB to LSB, i.e., first the bit at position length-1, + * then the bit at position length-2, and so on, till finally the bit at position 0. + */ +void RCSwitch::send(unsigned long code, unsigned int length) { + if (this->nTransmitterPin == -1) + return; + +#if not defined( RCSwitchDisableReceiving ) + // make sure the receiver is disabled while we transmit + int nReceiverInterrupt_backup = nReceiverInterrupt; + if (nReceiverInterrupt_backup != -1) { + this->disableReceive(); + } +#endif + + for (int nRepeat = 0; nRepeat < nRepeatTransmit; nRepeat++) { + for (int i = length-1; i >= 0; i--) { + if (code & (1L << i)) + this->transmit(protocol.one); + else + this->transmit(protocol.zero); + } + this->transmit(protocol.syncFactor); + } + + // Disable transmit after sending (i.e., for inverted protocols) + digitalWrite(this->nTransmitterPin, LOW); + +#if not defined( RCSwitchDisableReceiving ) + // enable receiver again if we just disabled it + if (nReceiverInterrupt_backup != -1) { + this->enableReceive(nReceiverInterrupt_backup); + } +#endif +} + +/** + * Transmit a single high-low pulse. + */ +void RCSwitch::transmit(HighLow pulses) { + uint8_t firstLogicLevel = (this->protocol.invertedSignal) ? LOW : HIGH; + uint8_t secondLogicLevel = (this->protocol.invertedSignal) ? HIGH : LOW; + + digitalWrite(this->nTransmitterPin, firstLogicLevel); + delayMicroseconds( this->protocol.pulseLength * pulses.high); + digitalWrite(this->nTransmitterPin, secondLogicLevel); + delayMicroseconds( this->protocol.pulseLength * pulses.low); +} + + +#if not defined( RCSwitchDisableReceiving ) +/** + * Enable receiving data + */ +void RCSwitch::enableReceive(int interrupt) { + this->nReceiverInterrupt = interrupt; + this->enableReceive(); +} + +void RCSwitch::enableReceive() { + if (this->nReceiverInterrupt != -1) { + RCSwitch::pendingChanges = 0; + RCSwitch::nReceivedValue = 0; + RCSwitch::nReceivedBitlength = 0; +#if defined(RaspberryPi) // Raspberry Pi + wiringPiISR(this->nReceiverInterrupt, INT_EDGE_BOTH, &handleInterrupt); +#else // Arduino + attachInterrupt(this->nReceiverInterrupt, handleInterrupt, CHANGE); +#endif + } +} + +/** + * Disable receiving data + */ +void RCSwitch::disableReceive() { +#if not defined(RaspberryPi) // Arduino + detachInterrupt(this->nReceiverInterrupt); +#endif // For Raspberry Pi (wiringPi) you can't unregister the ISR + this->nReceiverInterrupt = -1; +} + +volatile uint8_t RCSwitch::pendingChanges = 0; + +bool RCSwitch::available() { + // The ISR leaves this single buffer untouched until decoding completes. + // UART interrupts remain enabled during division/protocol matching. + const uint8_t count = pendingChanges; + if (count != 0) { + for (unsigned int i = 1; i <= numProto; ++i) { + if (receiveProtocol(i, count)) break; + } + pendingChanges = 0; + } + return RCSwitch::nReceivedValue != 0; +} + +void RCSwitch::resetAvailable() { + RCSwitch::nReceivedValue = 0; +} + +unsigned long RCSwitch::getReceivedValue() { + return RCSwitch::nReceivedValue; +} + +unsigned int RCSwitch::getReceivedBitlength() { + return RCSwitch::nReceivedBitlength; +} + +unsigned int RCSwitch::getReceivedDelay() { + return RCSwitch::nReceivedDelay; +} + +unsigned int RCSwitch::getReceivedProtocol() { + return RCSwitch::nReceivedProtocol; +} + +unsigned int* RCSwitch::getReceivedRawdata() { + return RCSwitch::timings; +} + +/* helper function for the receiveProtocol method */ +static inline unsigned int diff(int A, int B) { + return abs(A - B); +} + +/** + * + */ +bool RECEIVE_ATTR RCSwitch::receiveProtocol(const int p, unsigned int changeCount) { +#if defined(ESP8266) || defined(ESP32) + const Protocol &pro = proto[p-1]; +#else + Protocol pro; + memcpy_P(&pro, &proto[p-1], sizeof(Protocol)); +#endif + + unsigned long code = 0; + //Assuming the longer pulse length is the pulse captured in timings[0] + const unsigned int syncLengthInPulses = ((pro.syncFactor.low) > (pro.syncFactor.high)) ? (pro.syncFactor.low) : (pro.syncFactor.high); + const unsigned int delay = RCSwitch::timings[0] / syncLengthInPulses; + const unsigned int delayTolerance = delay * RCSwitch::nReceiveTolerance / 100; + + /* For protocols that start low, the sync period looks like + * _________ + * _____________| |XXXXXXXXXXXX| + * + * |--1st dur--|-2nd dur-|-Start data-| + * + * The 3rd saved duration starts the data. + * + * For protocols that start high, the sync period looks like + * + * ______________ + * | |____________|XXXXXXXXXXXXX| + * + * |-filtered out-|--1st dur--|--Start data--| + * + * The 2nd saved duration starts the data + */ + const unsigned int firstDataTiming = (pro.invertedSignal) ? (2) : (1); + + for (unsigned int i = firstDataTiming; i < changeCount - 1; i += 2) { + code <<= 1; + if (diff(RCSwitch::timings[i], delay * pro.zero.high) < delayTolerance && + diff(RCSwitch::timings[i + 1], delay * pro.zero.low) < delayTolerance) { + // zero + } else if (diff(RCSwitch::timings[i], delay * pro.one.high) < delayTolerance && + diff(RCSwitch::timings[i + 1], delay * pro.one.low) < delayTolerance) { + // one + code |= 1; + } else { + // Failed + return false; + } + } + + if (changeCount > 7) { // ignore very short transmissions: no device sends them, so this must be noise + RCSwitch::nReceivedValue = code; + RCSwitch::nReceivedBitlength = (changeCount - 1) / 2; + RCSwitch::nReceivedDelay = delay; + RCSwitch::nReceivedProtocol = p; + return true; + } + + return false; +} + +void RECEIVE_ATTR RCSwitch::handleInterrupt() { + // Drop edges while the main loop owns the captured frame; the next long + // gap resynchronizes capture. Do not re-enable interrupts inside this ISR. + if (pendingChanges != 0) return; + + static unsigned int changeCount = 0; + static unsigned long lastTime = 0; + static unsigned int repeatCount = 0; + + const long time = micros(); + const unsigned int duration = time - lastTime; + + if (duration > RCSwitch::nSeparationLimit) { + // A long stretch without signal level change occurred. This could + // be the gap between two transmission. + if ((repeatCount==0) || (diff(duration, RCSwitch::timings[0]) < 200)) { + // This long signal is close in length to the long signal which + // started the previously recorded timings; this suggests that + // it may indeed by a a gap between two transmissions (we assume + // here that a sender will send the signal multiple times, + // with roughly the same gap between them). + repeatCount++; + if (repeatCount == 2) { + repeatCount = 0; + if (changeCount > 7) { + pendingChanges = static_cast(changeCount); + changeCount = 0; + lastTime = time; + return; + } + } + } + changeCount = 0; + } + + // detect overflow + if (changeCount >= RCSWITCH_MAX_CHANGES) { + changeCount = 0; + repeatCount = 0; + } + + RCSwitch::timings[changeCount++] = duration; + lastTime = time; +} +#endif diff --git a/LocalLib/RCSwitch.h b/LocalLib/RCSwitch.h new file mode 100644 index 00000000..f55e0cab --- /dev/null +++ b/LocalLib/RCSwitch.h @@ -0,0 +1,186 @@ +// PCController: rc-switch 2.6.4, LGPL-2.1-or-later. Decode outside the ISR. +/* + RCSwitch - Arduino libary for remote control outlet switches + Copyright (c) 2011 Suat Özgür. All right reserved. + + Contributors: + - Andre Koehler / info(at)tomate-online(dot)de + - Gordeev Andrey Vladimirovich / gordeev(at)openpyro(dot)com + - Skineffect / http://forum.ardumote.com/viewtopic.php?f=2&t=46 + - Dominik Fischer / dom_fischer(at)web(dot)de + - Frank Oltmanns / .(at)gmail(dot)com + - Max Horn / max(at)quendi(dot)de + - Robert ter Vehn / .(at)gmail(dot)com + + Project home: https://github.com/sui77/rc-switch/ + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +#ifndef _RCSwitch_h +#define _RCSwitch_h + +#if defined(ARDUINO) && ARDUINO >= 100 + #include "Arduino.h" +#elif defined(ENERGIA) // LaunchPad, FraunchPad and StellarPad specific + #include "Energia.h" +#elif defined(RPI) // Raspberry Pi + #define RaspberryPi + + // Include libraries for RPi: + #include /* memcpy */ + #include /* abs */ + #include +#elif defined(SPARK) + #include "application.h" +#else + #include "WProgram.h" +#endif + +#include + + +// At least for the ATTiny X4/X5, receiving has to be disabled due to +// missing libm depencies (udivmodhi4) +#if defined( __AVR_ATtinyX5__ ) or defined ( __AVR_ATtinyX4__ ) +#define RCSwitchDisableReceiving +#endif + +// Number of maximum high/Low changes per packet. +// We can handle up to (unsigned long) => 32 bit * 2 H/L changes per bit + 2 for sync +#define RCSWITCH_MAX_CHANGES 67 + +class RCSwitch { + + public: + RCSwitch(); + + void switchOn(int nGroupNumber, int nSwitchNumber); + void switchOff(int nGroupNumber, int nSwitchNumber); + void switchOn(const char* sGroup, int nSwitchNumber); + void switchOff(const char* sGroup, int nSwitchNumber); + void switchOn(char sFamily, int nGroup, int nDevice); + void switchOff(char sFamily, int nGroup, int nDevice); + void switchOn(const char* sGroup, const char* sDevice); + void switchOff(const char* sGroup, const char* sDevice); + void switchOn(char sGroup, int nDevice); + void switchOff(char sGroup, int nDevice); + + void sendTriState(const char* sCodeWord); + void send(unsigned long code, unsigned int length); + void send(const char* sCodeWord); + + #if not defined( RCSwitchDisableReceiving ) + void enableReceive(int interrupt); + void enableReceive(); + void disableReceive(); + bool available(); + void resetAvailable(); + + unsigned long getReceivedValue(); + unsigned int getReceivedBitlength(); + unsigned int getReceivedDelay(); + unsigned int getReceivedProtocol(); + unsigned int* getReceivedRawdata(); + #endif + + void enableTransmit(int nTransmitterPin); + void disableTransmit(); + void setPulseLength(int nPulseLength); + void setRepeatTransmit(int nRepeatTransmit); + #if not defined( RCSwitchDisableReceiving ) + void setReceiveTolerance(int nPercent); + #endif + + /** + * Description of a single pule, which consists of a high signal + * whose duration is "high" times the base pulse length, followed + * by a low signal lasting "low" times the base pulse length. + * Thus, the pulse overall lasts (high+low)*pulseLength + */ + struct HighLow { + uint8_t high; + uint8_t low; + }; + + /** + * A "protocol" describes how zero and one bits are encoded into high/low + * pulses. + */ + struct Protocol { + /** base pulse length in microseconds, e.g. 350 */ + uint16_t pulseLength; + + HighLow syncFactor; + HighLow zero; + HighLow one; + + /** + * If true, interchange high and low logic levels in all transmissions. + * + * By default, RCSwitch assumes that any signals it sends or receives + * can be broken down into pulses which start with a high signal level, + * followed by a a low signal level. This is e.g. the case for the + * popular PT 2260 encoder chip, and thus many switches out there. + * + * But some devices do it the other way around, and start with a low + * signal level, followed by a high signal level, e.g. the HT6P20B. To + * accommodate this, one can set invertedSignal to true, which causes + * RCSwitch to change how it interprets any HighLow struct FOO: It will + * then assume transmissions start with a low signal lasting + * FOO.high*pulseLength microseconds, followed by a high signal lasting + * FOO.low*pulseLength microseconds. + */ + bool invertedSignal; + }; + + void setProtocol(Protocol protocol); + void setProtocol(int nProtocol); + void setProtocol(int nProtocol, int nPulseLength); + + private: + char* getCodeWordA(const char* sGroup, const char* sDevice, bool bStatus); + char* getCodeWordB(int nGroupNumber, int nSwitchNumber, bool bStatus); + char* getCodeWordC(char sFamily, int nGroup, int nDevice, bool bStatus); + char* getCodeWordD(char group, int nDevice, bool bStatus); + void transmit(HighLow pulses); + + #if not defined( RCSwitchDisableReceiving ) + static void handleInterrupt(); + static bool receiveProtocol(const int p, unsigned int changeCount); + int nReceiverInterrupt; + #endif + int nTransmitterPin; + int nRepeatTransmit; + + Protocol protocol; + + #if not defined( RCSwitchDisableReceiving ) + static int nReceiveTolerance; + volatile static unsigned long nReceivedValue; + volatile static unsigned int nReceivedBitlength; + volatile static unsigned int nReceivedDelay; + volatile static unsigned int nReceivedProtocol; + const static unsigned int nSeparationLimit; + /* + * timings[0] contains sync timing, followed by a number of bits + */ + static unsigned int timings[RCSWITCH_MAX_CHANGES]; + volatile static uint8_t pendingChanges; + #endif + + +}; + +#endif diff --git a/PCController.ino b/PCController.ino index 3912a875..608be2a6 100644 --- a/PCController.ino +++ b/PCController.ino @@ -3,7 +3,11 @@ #include "ProjectConfig.h" +#if defined(PCCONTROLLER_NATIVE) #include +#else +#include "LocalLib/RCSwitch.h" +#endif #include "LocalLib/BoardPins.h" #include "LocalLib/DallasTemperatureBus.h" diff --git a/Tools/Firmware/tests/rf-deferred/Arduino.h b/Tools/Firmware/tests/rf-deferred/Arduino.h new file mode 100644 index 00000000..19d30125 --- /dev/null +++ b/Tools/Firmware/tests/rf-deferred/Arduino.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include +#include +#define PROGMEM +#define HIGH 1 +#define LOW 0 +#define OUTPUT 1 +#define CHANGE 1 +extern unsigned long fakeMicros; +extern bool inInterrupt; +extern void (*edgeHandler)(); +inline unsigned long micros() { return fakeMicros; } +inline void pinMode(int,int) {} +inline void digitalWrite(int,int) {} +inline void delayMicroseconds(unsigned int) {} +inline void attachInterrupt(int,void (*handler)(),int) { edgeHandler=handler; } +inline void detachInterrupt(int) { edgeHandler=nullptr; } +inline void* memcpy_P(void* destination,const void* source,size_t count) { + assert(!inInterrupt && "RF protocol decoding must never run in the ISR"); + return std::memcpy(destination,source,count); +} diff --git a/Tools/Firmware/tests/rf-deferred/main.cpp b/Tools/Firmware/tests/rf-deferred/main.cpp new file mode 100644 index 00000000..f2c4e7c6 --- /dev/null +++ b/Tools/Firmware/tests/rf-deferred/main.cpp @@ -0,0 +1,33 @@ +#include "Arduino.h" +#include "../../../../LocalLib/RCSwitch.h" +#include +unsigned long fakeMicros=0; +bool inInterrupt=false; +void (*edgeHandler)()=nullptr; +void edge(unsigned int duration) { + fakeMicros+=duration; assert(edgeHandler); inInterrupt=true; + edgeHandler(); inInterrupt=false; +} +void packet(unsigned int code) { + for(int bit=7;bit>=0;--bit) { + edge((code&(1< Date: Tue, 27 Oct 2026 00:02:28 +0330 Subject: [PATCH 06/29] style: normalize vendored RF source whitespace --- LocalLib/RCSwitch.cpp | 22 +++++++++++----------- LocalLib/RCSwitch.h | 14 +++++++------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/LocalLib/RCSwitch.cpp b/LocalLib/RCSwitch.cpp index 768dbb1c..d92bccb3 100644 --- a/LocalLib/RCSwitch.cpp +++ b/LocalLib/RCSwitch.cpp @@ -2,7 +2,7 @@ /* RCSwitch - Arduino libary for remote control outlet switches Copyright (c) 2011 Suat Özgür. All right reserved. - + Contributors: - Andre Koehler / info(at)tomate-online(dot)de - Gordeev Andrey Vladimirovich / gordeev(at)openpyro(dot)com @@ -14,8 +14,8 @@ - Robert ter Vehn / .(at)gmail(dot)com - Johann Richard / .(at)gmail(dot)com - Vlad Gheorghe / .(at)gmail(dot)com https://github.com/vgheo - - Matias Cuenca-Acuna - + - Matias Cuenca-Acuna + Project home: https://github.com/sui77/rc-switch/ This library is free software; you can redistribute it and/or @@ -58,7 +58,7 @@ /* Format for protocol definitions: * {pulselength, Sync bit, "0" bit, "1" bit, invertedSignal} - * + * * pulselength: pulse length in microseconds, e.g. 350 * Sync bit: {1, 31} means 1 high pulse and 31 low pulses * (perceived as a 31*pulselength long pulse, total length of sync bit is @@ -174,7 +174,7 @@ void RCSwitch::setReceiveTolerance(int nPercent) { RCSwitch::nReceiveTolerance = nPercent; } #endif - + /** * Enable transmissions @@ -377,7 +377,7 @@ char* RCSwitch::getCodeWordC(char sFamily, int nGroup, int nDevice, bool bStatus if ( nFamily < 0 || nFamily > 15 || nGroup < 1 || nGroup > 4 || nDevice < 1 || nDevice > 4) { return 0; } - + // encode the family into four bits sReturn[nReturnPos++] = (nFamily & 1) ? 'F' : '0'; sReturn[nReturnPos++] = (nFamily & 2) ? 'F' : '0'; @@ -413,7 +413,7 @@ char* RCSwitch::getCodeWordC(char sFamily, int nGroup, int nDevice, bool bStatus * * Source: http://www.the-intruder.net/funksteckdosen-von-rev-uber-arduino-ansteuern/ * - * @param sGroup Name of the switch group (A..D, resp. a..d) + * @param sGroup Name of the switch group (A..D, resp. a..d) * @param nDevice Number of the switch itself (1..3) * @param bStatus Whether to switch on (true) or off (false) * @@ -535,7 +535,7 @@ void RCSwitch::send(unsigned long code, unsigned int length) { void RCSwitch::transmit(HighLow pulses) { uint8_t firstLogicLevel = (this->protocol.invertedSignal) ? LOW : HIGH; uint8_t secondLogicLevel = (this->protocol.invertedSignal) ? HIGH : LOW; - + digitalWrite(this->nTransmitterPin, firstLogicLevel); delayMicroseconds( this->protocol.pulseLength * pulses.high); digitalWrite(this->nTransmitterPin, secondLogicLevel); @@ -635,7 +635,7 @@ bool RECEIVE_ATTR RCSwitch::receiveProtocol(const int p, unsigned int changeCoun const unsigned int syncLengthInPulses = ((pro.syncFactor.low) > (pro.syncFactor.high)) ? (pro.syncFactor.low) : (pro.syncFactor.high); const unsigned int delay = RCSwitch::timings[0] / syncLengthInPulses; const unsigned int delayTolerance = delay * RCSwitch::nReceiveTolerance / 100; - + /* For protocols that start low, the sync period looks like * _________ * _____________| |XXXXXXXXXXXX| @@ -715,7 +715,7 @@ void RECEIVE_ATTR RCSwitch::handleInterrupt() { } changeCount = 0; } - + // detect overflow if (changeCount >= RCSWITCH_MAX_CHANGES) { changeCount = 0; @@ -723,6 +723,6 @@ void RECEIVE_ATTR RCSwitch::handleInterrupt() { } RCSwitch::timings[changeCount++] = duration; - lastTime = time; + lastTime = time; } #endif diff --git a/LocalLib/RCSwitch.h b/LocalLib/RCSwitch.h index f55e0cab..9986961c 100644 --- a/LocalLib/RCSwitch.h +++ b/LocalLib/RCSwitch.h @@ -11,7 +11,7 @@ - Frank Oltmanns / .(at)gmail(dot)com - Max Horn / max(at)quendi(dot)de - Robert ter Vehn / .(at)gmail(dot)com - + Project home: https://github.com/sui77/rc-switch/ This library is free software; you can redistribute it and/or @@ -65,7 +65,7 @@ class RCSwitch { public: RCSwitch(); - + void switchOn(int nGroupNumber, int nSwitchNumber); void switchOff(int nGroupNumber, int nSwitchNumber); void switchOn(const char* sGroup, int nSwitchNumber); @@ -80,7 +80,7 @@ class RCSwitch { void sendTriState(const char* sCodeWord); void send(unsigned long code, unsigned int length); void send(const char* sCodeWord); - + #if not defined( RCSwitchDisableReceiving ) void enableReceive(int interrupt); void enableReceive(); @@ -94,7 +94,7 @@ class RCSwitch { unsigned int getReceivedProtocol(); unsigned int* getReceivedRawdata(); #endif - + void enableTransmit(int nTransmitterPin); void disableTransmit(); void setPulseLength(int nPulseLength); @@ -163,7 +163,7 @@ class RCSwitch { #endif int nTransmitterPin; int nRepeatTransmit; - + Protocol protocol; #if not defined( RCSwitchDisableReceiving ) @@ -173,14 +173,14 @@ class RCSwitch { volatile static unsigned int nReceivedDelay; volatile static unsigned int nReceivedProtocol; const static unsigned int nSeparationLimit; - /* + /* * timings[0] contains sync timing, followed by a number of bits */ static unsigned int timings[RCSWITCH_MAX_CHANGES]; volatile static uint8_t pendingChanges; #endif - + }; #endif From 4429e39cd91a60cb2e76885d71cd033016df8a76 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:06:51 +0330 Subject: [PATCH 07/29] docs: record extended strip serial bench result --- docs/RF-DEFERRED-DECODE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/RF-DEFERRED-DECODE.md b/docs/RF-DEFERRED-DECODE.md index 695e8846..16e1e662 100644 --- a/docs/RF-DEFERRED-DECODE.md +++ b/docs/RF-DEFERRED-DECODE.md @@ -25,6 +25,11 @@ Bench investigation is ongoing: the original 38-pixel run rejected a staged packet after 498 acknowledged frames, increasing framing errors by one without CRC errors or resets. The first deferred-decoding run acknowledged 588 frames with zero framing/CRC errors, then hit a host request deadline. Longer serial and -host scheduling tests are required before claiming full resolution. Temporary +host scheduling tests are required before claiming full resolution. A subsequent +180-second, 38-pixel chase acknowledged 2,442 of 2,443 attempted frames, with no +framing errors, CRC errors, or resets. One HTTP client request exceeded its +five-second deadline; its physical outcome is unknown. Later animation frames +continued without replaying that request. This is evidence for improved UART +reception, not proof that all host latency issues are resolved. Temporary UART tracing is local bench instrumentation and must be removed before final production delivery. No physical RF remote acceptance is claimed by the fixture. From 1f2295723faff84b7b880798a33301447f3c7d6e Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:13:10 +0330 Subject: [PATCH 08/29] docs: checkpoint verified RF firmware and integration handoff --- .github/scripts/repository-check.mjs | 7 +++++++ docs/RF-DEFERRED-DECODE.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.github/scripts/repository-check.mjs b/.github/scripts/repository-check.mjs index 33b5b351..29e3e534 100644 --- a/.github/scripts/repository-check.mjs +++ b/.github/scripts/repository-check.mjs @@ -188,6 +188,13 @@ try { // the exception file- and value-specific so docs and all other source remain // subject to the normal external-repository privacy gate. const reviewedRepositoryFixtures = new Map([ + // Upstream attribution retained in the vendored LGPL RF library. + ["LocalLib/RCSwitch.cpp", new Set([ + ["https://github", ".com/sui77/rc-switch"].join(""), + ])], + ["LocalLib/RCSwitch.h", new Set([ + ["https://github", ".com/sui77/rc-switch"].join(""), + ])], ["Tools/Controller/README.md", new Set([ ["https://github", ".com/atomicdeploy/PCController"].join(""), ])], diff --git a/docs/RF-DEFERRED-DECODE.md b/docs/RF-DEFERRED-DECODE.md index 16e1e662..313b62d9 100644 --- a/docs/RF-DEFERRED-DECODE.md +++ b/docs/RF-DEFERRED-DECODE.md @@ -33,3 +33,31 @@ continued without replaying that request. This is evidence for improved UART reception, not proof that all host latency issues are resolved. Temporary UART tracing is local bench instrumentation and must be removed before final production delivery. No physical RF remote acceptance is claimed by the fixture. + +## Bench checkpoint + +The temporary tracing was removed for firmware identity `5B86029E`. The final +build uses 32,334 flash bytes and 1,571 static SRAM bytes; the existing final-LTO +serial-response-plus-INT0 guard estimates 269 bytes of margin. The linked strip +waveform fixture passes all 256 byte values, byte boundaries and a 300-byte frame. +The programmer's immediate verify passed, but its separate readback initially +failed to synchronize. A read-only retry succeeded. Guarded recovery then verified +32,346 written bytes including the identity section, restored and verified EEPROM +settings, cleared its recovery marker and released the programming latch. + +Pealayer's individual-frame path was also exercised for 60 seconds before that +final upload: 166 frame requests were accepted, with 105 waits for its pending +operation to finish. No new board framing/CRC errors, resets or Pealayer clock +failures were observed. API acceptance is not an optical acknowledgement. This +path is suitable for manual diagnostics; smooth playback needs the host streaming +engine rather than frequent individual UI operations. + +The host connection and Pealayer automatic connection were restored after +recovery. A fresh 38-pixel configuration was acknowledged. Playback remains +paused and standalone chase/rainbow streaming is stopped so the separate playback +investigation owns effects. Long streaming validation on the final trace-free +image, the isolated host deadline, physical RF reception, synchronized playback, +the three effect demonstrations and seat stress acceptance remain open. The next +action is to reproduce the reported playback stop at its effect boundary with +the recovered host available, recording new error deltas rather than historical +maintenance disconnects. This checkpoint does not claim those acceptance gates. From cdbeac91061d0a4404235723fda729fb432ac6f2 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:15:29 +0330 Subject: [PATCH 09/29] fix: bound RF indices to preserve optional profile flash budget --- LocalLib/RCSwitch.cpp | 13 +++++++------ LocalLib/RCSwitch.h | 2 +- Tools/Firmware/tests/rf-deferred/main.cpp | 17 +++++++++++++---- docs/RF-DEFERRED-DECODE.md | 9 +++++++++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/LocalLib/RCSwitch.cpp b/LocalLib/RCSwitch.cpp index d92bccb3..a9929789 100644 --- a/LocalLib/RCSwitch.cpp +++ b/LocalLib/RCSwitch.cpp @@ -582,7 +582,7 @@ bool RCSwitch::available() { // UART interrupts remain enabled during division/protocol matching. const uint8_t count = pendingChanges; if (count != 0) { - for (unsigned int i = 1; i <= numProto; ++i) { + for (uint8_t i = 1; i <= numProto; ++i) { if (receiveProtocol(i, count)) break; } pendingChanges = 0; @@ -622,7 +622,7 @@ static inline unsigned int diff(int A, int B) { /** * */ -bool RECEIVE_ATTR RCSwitch::receiveProtocol(const int p, unsigned int changeCount) { +bool RECEIVE_ATTR RCSwitch::receiveProtocol(uint8_t p, uint8_t changeCount) { #if defined(ESP8266) || defined(ESP32) const Protocol &pro = proto[p-1]; #else @@ -653,9 +653,9 @@ bool RECEIVE_ATTR RCSwitch::receiveProtocol(const int p, unsigned int changeCoun * * The 2nd saved duration starts the data */ - const unsigned int firstDataTiming = (pro.invertedSignal) ? (2) : (1); + const uint8_t firstDataTiming = (pro.invertedSignal) ? (2) : (1); - for (unsigned int i = firstDataTiming; i < changeCount - 1; i += 2) { + for (uint8_t i = firstDataTiming; i < changeCount - 1; i += 2) { code <<= 1; if (diff(RCSwitch::timings[i], delay * pro.zero.high) < delayTolerance && diff(RCSwitch::timings[i + 1], delay * pro.zero.low) < delayTolerance) { @@ -686,9 +686,10 @@ void RECEIVE_ATTR RCSwitch::handleInterrupt() { // gap resynchronizes capture. Do not re-enable interrupts inside this ISR. if (pendingChanges != 0) return; - static unsigned int changeCount = 0; + static_assert(RCSWITCH_MAX_CHANGES < 255, "RF pulse indices must fit one byte"); + static uint8_t changeCount = 0; static unsigned long lastTime = 0; - static unsigned int repeatCount = 0; + static uint8_t repeatCount = 0; const long time = micros(); const unsigned int duration = time - lastTime; diff --git a/LocalLib/RCSwitch.h b/LocalLib/RCSwitch.h index 9986961c..c205c661 100644 --- a/LocalLib/RCSwitch.h +++ b/LocalLib/RCSwitch.h @@ -158,7 +158,7 @@ class RCSwitch { #if not defined( RCSwitchDisableReceiving ) static void handleInterrupt(); - static bool receiveProtocol(const int p, unsigned int changeCount); + static bool receiveProtocol(uint8_t p, uint8_t changeCount); int nReceiverInterrupt; #endif int nTransmitterPin; diff --git a/Tools/Firmware/tests/rf-deferred/main.cpp b/Tools/Firmware/tests/rf-deferred/main.cpp index f2c4e7c6..0e1eb093 100644 --- a/Tools/Firmware/tests/rf-deferred/main.cpp +++ b/Tools/Firmware/tests/rf-deferred/main.cpp @@ -8,10 +8,10 @@ void edge(unsigned int duration) { fakeMicros+=duration; assert(edgeHandler); inInterrupt=true; edgeHandler(); inInterrupt=false; } -void packet(unsigned int code) { - for(int bit=7;bit>=0;--bit) { - edge((code&(1<=0;--bit) { + edge((code&(1UL< Date: Tue, 27 Oct 2026 00:28:57 +0330 Subject: [PATCH 10/29] feat: integrate strip designer and deliberate pixel diagnostics --- Tools/Controller/internal/control/strip.go | 26 +++++- .../Controller/internal/control/strip_test.go | 25 +++++ docs/STRIP-WORKSPACE-INTEGRATION.md | 37 ++++++++ mobile/GO-FLUTTER-PARITY.md | 6 ++ mobile/UI-REQUIREMENT-LEDGER.md | 10 ++ mobile/lib/unified/effect_editor.dart | 9 ++ mobile/lib/unified/go_tools.dart | 4 +- mobile/lib/unified/pixel_diagnostic.dart | 49 ++++++++++ mobile/lib/unified/strip_designer.dart | 93 +++++++++++++++++++ mobile/test/strip_designer_test.dart | 16 ++++ 10 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 docs/STRIP-WORKSPACE-INTEGRATION.md create mode 100644 mobile/lib/unified/pixel_diagnostic.dart create mode 100644 mobile/lib/unified/strip_designer.dart create mode 100644 mobile/test/strip_designer_test.dart diff --git a/Tools/Controller/internal/control/strip.go b/Tools/Controller/internal/control/strip.go index e2fd00f6..8e02ff10 100644 --- a/Tools/Controller/internal/control/strip.go +++ b/Tools/Controller/internal/control/strip.go @@ -138,7 +138,7 @@ func stripStreamCommand(ctx context.Context, outputs *OutputScheduler, args []st return "", err } outputs.stop("strip") - if err = outputs.sendStripFrame(ctx, rgb); err != nil { + if err = outputs.sendConfiguredStripFrame(ctx, rgb); err != nil { return "", err } return fmt.Sprintf("strip frame displayed: %d LEDs", len(rgb)/3), nil @@ -272,12 +272,34 @@ func (outputs *OutputScheduler) streamStripProgram(ctx context.Context, count, f } func (outputs *OutputScheduler) sendStripFrame(ctx context.Context, rgb []byte) error { - frames, err := native.StripFramePayloads(rgb) + outputs.stripMu.Lock() + defer outputs.stripMu.Unlock() + return outputs.sendStripFrameLocked(ctx, rgb) +} + +// A deliberate full-frame diagnostic owns its exact length, including when the +// previous animation used a different count. Continuous streams configure once. +func (outputs *OutputScheduler) sendConfiguredStripFrame(ctx context.Context, rgb []byte) error { + if _, err := native.StripFramePayloads(rgb); err != nil { + return err + } + configuration, err := native.StripConfigurePayload(len(rgb) / 3) if err != nil { return err } outputs.stripMu.Lock() defer outputs.stripMu.Unlock() + if err := outputs.sendStrip(ctx, configuration); err != nil { + return err + } + return outputs.sendStripFrameLocked(ctx, rgb) +} + +func (outputs *OutputScheduler) sendStripFrameLocked(ctx context.Context, rgb []byte) error { + frames, err := native.StripFramePayloads(rgb) + if err != nil { + return err + } for _, payload := range frames { if err := ctx.Err(); err != nil { return err diff --git a/Tools/Controller/internal/control/strip_test.go b/Tools/Controller/internal/control/strip_test.go index 5e6fb902..eaca5f60 100644 --- a/Tools/Controller/internal/control/strip_test.go +++ b/Tools/Controller/internal/control/strip_test.go @@ -57,6 +57,31 @@ func TestStripFailureDoesNotCommitPartialFrame(t *testing.T) { } } +func TestManualStripFrameConfiguresExactLengthBeforeDisplay(t *testing.T) { + target := &recordingOutputTarget{} + outputs := NewOutputScheduler(target) + defer outputs.Close() + if _, err := stripStreamCommand(context.Background(), outputs, []string{"frame", "000000ff0000000000"}); err != nil { + t.Fatal(err) + } + commands := target.snapshotCommands() + if len(commands) != 3 || string(commands[0].payload) != string([]byte{0xFE, 3}) || commands[1].payload[0] != 0xFD || commands[2].payload[0] != 0xFC { + t.Fatalf("configuration/staging/show order = %#v", commands) + } +} + +func TestManualStripFrameDoesNotStageAfterFailedConfiguration(t *testing.T) { + target := &recordingOutputTarget{failAt: 1} + outputs := NewOutputScheduler(target) + defer outputs.Close() + if err := outputs.sendConfiguredStripFrame(context.Background(), []byte{255, 0, 0}); err == nil { + t.Fatal("expected configuration failure") + } + if len(target.snapshotCommands()) != 1 { + t.Fatal("staged after uncertain configuration") + } +} + func TestStripRainbowStopsWithoutMoreFrames(t *testing.T) { target := &recordingOutputTarget{} outputs := NewOutputScheduler(target) diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md new file mode 100644 index 00000000..ee33682d --- /dev/null +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -0,0 +1,37 @@ +# Strip workspace integration + +The Flutter effect dialog now supports typed strip programs, the three original +presets, Fade/Pulse envelopes and an offline pixel preview. Unapplied technical +JSON disables typed editing; invalid programs prevent saving. Strip preview uses +the current `effect play ID COUNT FPS` command, matching native Pealayer. + +Manual diagnostics adapt PakarController's deliberate full-frame probe contract +to a linear 1–100-pixel strip. Opening, selecting and moving the cursor never +publishes. Each lit probe consumes one arm bound to the selected peer; the arm +expires after 30 seconds. Connection changes revoke it. Off and each subsequent +probe are separate explicit requests; no failed/uncertain mutation is replayed. +The complete frame zeros every unselected pixel within its selected count. + +`strip frame` now configures the exact frame length before staging/show, under +the same strip lock. A failed configuration cannot stage or display the frame. +Continuous animations retain their configure-once path and existing pacing. +This host change must be deployed with the diagnostics clients. + +PCController's current AVR contract does not expose RGB frame readback. These +controls therefore report command acknowledgement, not PakarController-style +board RGB observations or optical verification. Unknown physical output remains +unknown until the operator checks it; source tests do not establish that result. + +Validation: targeted Go manual-frame and streaming regressions; Flutter preset, +envelope, bounds, full-frame clearing and unapplied-draft fixtures. Runtime and +Android package acceptance remain open until an exact candidate is deployed and +tested. Native Pealayer owns the corresponding typed designer and probe UI in +its strip-workspace work. Playback-clock correction is coordinated separately. + +The live 15-second seat test submitted 150 Up requests through Pealayer at 10 Hz. +No framing/CRC/reset delta was observed; final telemetry reported both directions +Stop and all relays off. Identical Up requests coalesce while already moving, so +this does not imply 150 physical relay cycles. Human motion acceptance is pending. +The three existing strip definitions retain their stable IDs and are grouped +with Fade/Pulse in Strip demos with three-pixel defaults for the current bench. +Private settings backups and live logs are not included in this repository. diff --git a/mobile/GO-FLUTTER-PARITY.md b/mobile/GO-FLUTTER-PARITY.md index d3742a93..29cfd164 100644 --- a/mobile/GO-FLUTTER-PARITY.md +++ b/mobile/GO-FLUTTER-PARITY.md @@ -94,3 +94,9 @@ 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. + +Strip effects now use the current `effect play ID COUNT FPS` command from the +native workbench. The shared effect dialog includes typed strip programs and +Fade/Pulse envelope presets. Deliberate full-frame probes use `strip frame` with +peer fencing and no mutation replay. Current AVR firmware provides no RGB frame +readback; acknowledgement and physical observation remain distinct. diff --git a/mobile/UI-REQUIREMENT-LEDGER.md b/mobile/UI-REQUIREMENT-LEDGER.md index 67a94806..031cd436 100644 --- a/mobile/UI-REQUIREMENT-LEDGER.md +++ b/mobile/UI-REQUIREMENT-LEDGER.md @@ -39,3 +39,13 @@ Final delivery observed 2026-10-13T18:20:30.258795+00:00: 0.1.17/code18 Android Swipe source verification completed: final analyzer clean; one opt-in renderer produced nine phone/tablet/theme/editor fixtures and the phone Live frame was inspected. Gesture behavior is established by the 32 focused checks rather than static screenshots. No native backend/runtime, device installer, output or firmware action. Swipe delivery observed 2026-10-13T18:38:00.154038+00:00: 0.1.18/code19 Android release from a603ecaa216760aba8c8482ce3e897bb0aea1255, mobile tree 04923307280f10a21fec4df1ca4b6c8228af1920; 20,332,384 bytes, SHA256 61c1046709ced11edcb96f6554d377513688189d42f1e481c9bcbe31f540dc47. Package/inherited signer verified. SSH localhost:5022 SM-S908E received /storage/emulated/0/Download/PCController-0.1.18.apk, with exact final hash/size verified after temporary-file promotion; 2,321,252 KiB free afterward. Ready for manual installation; no installer or controller launch. ADB and Android mDNS inventories empty, so no reachable ADB tablet update. PR #673 contains the swipe topic stacked on #671. Source/build/transfer are distinct from actual Android touch, installation and human acceptance; no native runtime replacement or board action. + +## Strip workspace checkpoint +Typed strip authoring, offline pixel preview, five presets (Police, Thunder, +Converging, Fade, Pulse), and explicit peer-bound one-shot pixel probes now have +source implementations. Technical drafts remain authoritative until applied. +Manual RGB frames configure their exact count before stage/show; stream pacing +is unchanged. Source tests are separate from installed APK, visual, physical +and audio acceptance. Board RGB readback is not advertised by the current AVR +contract; diagnostic receipts mean host acknowledgement only. See +../docs/STRIP-WORKSPACE-INTEGRATION.md for remaining gates. diff --git a/mobile/lib/unified/effect_editor.dart b/mobile/lib/unified/effect_editor.dart index 256fa7e9..1a55aaa0 100644 --- a/mobile/lib/unified/effect_editor.dart +++ b/mobile/lib/unified/effect_editor.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'transport.dart'; import 'sequence_timeline.dart'; import 'controller_dialog.dart'; +import 'strip_designer.dart'; class EffectEditor extends StatefulWidget { const EffectEditor({ @@ -375,6 +376,7 @@ class _EffectEditorState extends State { ), ], ), + if (effect['kind'] == 'strip-stream') StripDesigner(effect: effect, enabled: typedEditing, changed: (value) => setState(() { effect = value; syncRaw(); })), if (effect['kind'] == 'sequence') ...[ const SizedBox(height: 20), SequenceTimeline( @@ -690,6 +692,13 @@ int effectSpanUs(List steps) => steps.fold(0, (span, step) { }); String? effectDefinitionError(Json effect) { + if (effect['kind'] == 'strip-stream') { + for(final entry in {'default_pixels':100,'default_fps':30,'duration_ms':3600000}.entries) { + final v=effect[entry.key]; + if(v is! int || v < (entry.key=='duration_ms'?100:1) || v > entry.value)return 'Invalid ${entry.key.replaceAll('_',' ')}.'; + } + return stripProgramError(object(effect['program'])); + } if (effect['steps'] != null && (effect['steps'] is! List || (effect['steps'] as List).any((value) => value is! Map))) { diff --git a/mobile/lib/unified/go_tools.dart b/mobile/lib/unified/go_tools.dart index cbf1fb31..b64e90f7 100644 --- a/mobile/lib/unified/go_tools.dart +++ b/mobile/lib/unified/go_tools.dart @@ -9,6 +9,7 @@ import 'endpoint_dialog.dart' show AddressCharacterFormatter; import 'go_commands.dart'; import 'preferences.dart'; import 'transport.dart'; +import 'pixel_diagnostic.dart'; class GoToolsPage extends StatefulWidget { const GoToolsPage({ @@ -568,6 +569,7 @@ class _GoToolsPageState extends State { }[mode] ?? mode, }, (v) => stripMode = v), + if (modes.contains('frame')) ExpansionTile(title: const Text('Pixel diagnostics'), children: [PixelDiagnostic(client: client, count: pixels.clamp(1,maxPixels))]), const SizedBox(height: 12), pair( number( @@ -631,7 +633,7 @@ class _GoToolsPageState extends State { 'strip pixel $pixel ${rgb!.join(' ')} $brightness', 'rainbow' => 'strip rainbow $pixels $fps', 'effect' => - 'strip effect play ${nativeArgument(stripEffect!)} $pixels $fps', + 'effect play ${nativeArgument(stripEffect!)} $pixels $fps', _ => 'strip frame ${frame.text}', }; unawaited(run(value)); diff --git a/mobile/lib/unified/pixel_diagnostic.dart b/mobile/lib/unified/pixel_diagnostic.dart new file mode 100644 index 00000000..8f964557 --- /dev/null +++ b/mobile/lib/unified/pixel_diagnostic.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'go_commands.dart'; + +/// PakarController's deliberate, peer-bound probe adapted to a linear strip. +/// These bytes are requested output, not optical or board RGB readback. +List pixelProbe(int count,int index,List rgb) { + if(count<1||count>100||index<0||index>=count||rgb.length!=3||rgb.any((v)=>v<0||v>255))throw ArgumentError('Invalid pixel probe'); + final bytes=List.filled(count*3,0);bytes.setRange(index*3,index*3+3,rgb);return bytes; +} + +class PixelDiagnostic extends StatefulWidget { + const PixelDiagnostic({super.key,required this.client,required this.count}); + final GoCommandClient client;final int count; + @override State createState()=>_PixelDiagnosticState(); +} +class _PixelDiagnosticState extends State { + int index=0;String? armedOwner,receipt;bool pending=false;Timer? expiration; + bool get ready=>widget.client.current&&widget.client.session.boardReady&&!widget.client.busy&&!pending; + void disarm(){expiration?.cancel();armedOwner=null;} + @override void dispose(){expiration?.cancel();super.dispose();} + @override void didUpdateWidget(covariant PixelDiagnostic old){super.didUpdateWidget(old);if(old.count!=widget.count||old.client!=widget.client){disarm();index=index.clamp(0,widget.count-1);receipt=null;}} + Future send(List rgb,{bool off=false})async { + if(!ready||!off&&armedOwner!=widget.client.session.targetStamp)return; + disarm();setState((){pending=true;receipt=null;}); + try { + final bytes=off?List.filled(widget.count*3,0):pixelProbe(widget.count,index,rgb); + final hex=bytes.map((v)=>v.toRadixString(16).padLeft(2,'0')).join(); + await widget.client.run('strip frame $hex'); + if(mounted&&widget.client.current)setState(()=>receipt=off?'All-off frame acknowledged':'Pixel ${index+1} frame acknowledged'); + }catch(e){if(mounted)setState(()=>receipt='$e');} + finally{if(mounted)setState(()=>pending=false);} + } + @override Widget build(BuildContext context){ + if(!widget.client.current||!widget.client.session.boardReady||armedOwner!=null&&armedOwner!=widget.client.session.targetStamp)disarm(); + return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ + Text('Pixel diagnostics',style:Theme.of(context).textTheme.titleMedium), + Wrap(spacing:4,runSpacing:4,children:[for(var i=0;isetState(()=>index=i))]), + Row(children:[IconButton(tooltip:'Previous pixel',onPressed:!pending&&index>0?()=>setState(()=>index--):null,icon:const Icon(Icons.chevron_left)),Text('Pixel ${index+1} / ${widget.count}'),IconButton(tooltip:'Next pixel',onPressed:!pending&&index+1setState(()=>index++):null,icon:const Icon(Icons.chevron_right))]), + CheckboxListTile(contentPadding:EdgeInsets.zero,title:const Text('Arm one test'),value:armedOwner!=null,onChanged:ready?(value)=>setState((){disarm();if(value==true){armedOwner=widget.client.session.targetStamp;expiration=Timer(const Duration(seconds:30),(){if(mounted)setState(disarm);});}}):null), + Wrap(spacing:8,runSpacing:8,children:[ + for(final (name,rgb) in [('Red',[255,0,0]),('Green',[0,255,0]),('Blue',[0,0,255]),('White',[255,255,255])])Tooltip(message:'Light this pixel and clear all others',child:FilledButton.tonal(onPressed:ready&&armedOwner!=null?()=>unawaited(send(rgb)):null,child:Text(name))), + OutlinedButton(onPressed:ready?()=>unawaited(send([0,0,0],off:true)):null,child:const Text('All off')), + ]), + SizedBox(height:6,child:pending?const LinearProgressIndicator():null), + if(receipt!=null)SelectableText(receipt!), + ]); + } +} diff --git a/mobile/lib/unified/strip_designer.dart b/mobile/lib/unified/strip_designer.dart new file mode 100644 index 00000000..ca45be0f --- /dev/null +++ b/mobile/lib/unified/strip_designer.dart @@ -0,0 +1,93 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'transport.dart'; + +const stripPresetNames = ['Police red / blue', 'White thunder', 'Converging red dots', 'Fade', 'Pulse']; +Json stripPreset(int index) { + final primary = {'red': 255, 'green': 0, 'blue': 0}; + if (index == 0) return {'primitive':'alternating-zones','primary':primary,'secondary':{'red':0,'green':0,'blue':255},'period_ms':800,'step_ms':100,'swap_after_steps':4,'dim_intensity':36}; + if (index == 2) return {'primitive':'converging-points','primary':primary,'period_ms':2000,'tail_pixels':3}; + final points = index == 1 ? [[0,255],[45,24],[90,220],[145,52],[220,150],[360,0]] : index == 3 ? [[0,0],[1400,255],[2800,0]] : [[0,0],[500,255],[1000,0]]; + return {'primitive':'envelope','primary':index==1?{'red':255,'green':255,'blue':255}:{'red':0,'green':96,'blue':255},'period_ms':index==1?1600:3000,'envelope':[for(final p in points){'at_ms':p[0],'intensity':p[1]}]}; +} + +String? stripProgramError(Json p) { + bool bounded(dynamic v,int low,int high)=>v is int && v>=low && v<=high; + final period=p['period_ms']; + if(!bounded(period,50,3600000)) return 'Cycle must be 50–3600000 ms.'; + for(final key in ['primary','secondary']) { + if(p[key]!=null && (p[key] is! Map || ['red','green','blue'].any((c)=>!bounded(p[key][c],0,255)))) return 'Color channels must be 0–255.'; + } + switch(p['primitive']) { + case 'alternating-zones': + if(!bounded(p['step_ms'],10,period)||!bounded(p['swap_after_steps'],1,1024)||p['dim_intensity']!=null&&!bounded(p['dim_intensity'],0,255)) return 'Invalid flash timing or brightness.'; + case 'converging-points': + if(p['tail_pixels']!=null&&!bounded(p['tail_pixels'],0,100))return 'Tail must be 0–100 pixels.'; + case 'envelope': + final points=p['envelope']; + if(points is! List || points.length<2 || points.length>256)return 'Envelope needs 2–256 points.'; + var previous=-1; + for(final point in points){if(point is! Map || !bounded(point['at_ms'],previous+1,period-1)||!bounded(point['intensity'],0,255))return 'Envelope times must increase inside the cycle.';previous=point['at_ms'];} + default:return 'Choose a supported strip pattern.'; + } + return null; +} + +List stripPreview(Json p,int count,int at) { + if(stripProgramError(p)!=null||count<1||count>100)return []; + final period=p['period_ms'] as int,time=at%(p['period_ms'] as int); + Color color(String key,int intensity){final c=object(p[key]);int channel(String k)=>((c[k] as int? ?? 0)*intensity~/255).clamp(0,255);return Color.fromARGB(255,channel('red'),channel('green'),channel('blue'));} + return List.generate(count,(pixel){ + if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase.isEven?255:(p['dim_intensity'] as int? ?? 0));} + if(p['primitive']=='envelope'){final points=objects(p['envelope']);var level=0;for(var i=0;i0){final previous=points[i-1],start=previous['at_ms'] as int,a=previous['intensity'] as int,b=points[i]['intensity'] as int;level=a+(b-a)*(time-start)~/(end-start);}break;}level=points[i]['intensity'] as int;}return color('primary',level);} + final head=time*((count-1)~/2+1)~/period; + final configured=p['tail_pixels'] as int? ?? 0,tail=math.max(3,configured==0?count~/12:configured); + int intensity(int d)=>d<=tail?255*(tail+1-d)~/(tail+1):0; + return color('primary',math.max(pixel<=head?intensity(head-pixel):0,pixel>=count-1-head?intensity(pixel-(count-1-head)):0)); + }); +} + +class StripDesigner extends StatefulWidget { + const StripDesigner({super.key,required this.effect,required this.enabled,required this.changed}); + final Json effect; final bool enabled; final ValueChanged changed; + @override State createState()=>_StripDesignerState(); +} +class _StripDesignerState extends State { + double phase=0; + void program(Json p)=>widget.changed({...widget.effect,'program':p}); + Widget slider(String title,int value,int low,int high,ValueChanged change)=>Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Text('$title · $value'),Slider(value:value.clamp(low,high).toDouble(),min:low.toDouble(),max:high.toDouble(),onChanged:widget.enabled&&lowchange(v.round()):null)]); + @override Widget build(BuildContext context){ + final p=object(widget.effect['program']),error=stripProgramError(p),count=(widget.effect['default_pixels'] as int? ?? 3).clamp(1,100); + final points=objects(p['envelope']); + return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ + const SizedBox(height:16),Text('Strip pattern',style:Theme.of(context).textTheme.titleMedium), + Wrap(spacing:8,runSpacing:8,children:[for(var i=0;iprogram(stripPreset(i)):null)]), + if(error!=null)Padding(padding:const EdgeInsets.symmetric(vertical:12),child:Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error))), + if(error==null)...[ + const SizedBox(height:12), + slider('Pixels',count,1,100,(v)=>widget.changed({...widget.effect,'default_pixels':v})), + slider('FPS',widget.effect['default_fps'] as int? ?? 20,1,30,(v)=>widget.changed({...widget.effect,'default_fps':v})), + slider('Cycle (ms)',p['period_ms'],math.max(50,math.max(points.isEmpty?0:(points.last['at_ms'] as int)+1,p['step_ms'] as int? ?? 0)),3600000,(v)=>program({...p,'period_ms':v})), + for(final key in ['primary',if(p['primitive']=='alternating-zones')'secondary'])...[ + Text(key=='primary'?'Color':'Secondary color',style:Theme.of(context).textTheme.titleSmall), + for(final channel in ['red','green','blue'])slider(channel,object(p[key])[channel] as int? ?? 0,0,255,(v)=>program({...p,key:{...object(p[key]),channel:v}})), + ], + if(p['primitive']=='alternating-zones')...[ + slider('Flash interval (ms)',p['step_ms'],10,p['period_ms'],(v)=>program({...p,'step_ms':v})), + slider('Swap after flashes',p['swap_after_steps'],1,1024,(v)=>program({...p,'swap_after_steps':v})), + slider('Dim brightness',p['dim_intensity'] as int? ?? 0,0,255,(v)=>program({...p,'dim_intensity':v})), + ], + if(p['primitive']=='converging-points')slider('Tail pixels',p['tail_pixels'] as int? ?? 0,0,100,(v)=>program({...p,'tail_pixels':v})), + if(p['primitive']=='envelope')...[ + const Text('Brightness envelope'), + for(var i=0;iprogram({...p,'envelope':[for(var j=0;jprogram({...p,'envelope':[for(var j=0;jsetState(()=>phase=v)), + Wrap(spacing:4,runSpacing:4,children:[for(final (i,c) in stripPreview(p,count,(phase*(p['period_ms'] as int)-1).clamp(0,3600000).toInt()).indexed)Tooltip(message:'Pixel ${i+1}',child:Container(width:22,height:22,decoration:BoxDecoration(color:c,borderRadius:BorderRadius.circular(4),border:Border.all(color:Theme.of(context).dividerColor))))]), + ], + ]); + } +} diff --git a/mobile/test/strip_designer_test.dart b/mobile/test/strip_designer_test.dart new file mode 100644 index 00000000..981a5b05 --- /dev/null +++ b/mobile/test/strip_designer_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pccontroller_mobile/unified/strip_designer.dart'; +import 'package:pccontroller_mobile/unified/pixel_diagnostic.dart'; + +void main(){ + test('all presets support three physical pixels',(){for(var i=0;i<5;i++){final p=stripPreset(i);expect(stripProgramError(p),isNull);expect(stripPreview(p,3,1000),hasLength(3));}}); + test('invalid envelope is retained and cannot preview',(){final p=stripPreset(3);p['envelope'][1]['at_ms']=0;expect(stripProgramError(p),isNotNull);expect(stripPreview(p,3,0),isEmpty);expect(p['envelope'][1]['at_ms'],0);}); + test('fade and pulse timing differ',(){expect(stripPreview(stripPreset(3),3,500),isNot(stripPreview(stripPreset(4),3,500)));}); + test('probe clears every unselected pixel and rejects invalid bounds',(){final p=pixelProbe(38,37,[1,2,3]);expect(p,hasLength(114));expect(p.take(111).every((v)=>v==0),isTrue);expect(p.skip(111),[1,2,3]);expect(()=>pixelProbe(3,3,[255,0,0]),throwsArgumentError);expect(()=>pixelProbe(101,0,[255,0,0]),throwsArgumentError);}); + testWidgets('unapplied technical draft disables preset replacement',(tester)async{ + var changed=false; + await tester.pumpWidget(MaterialApp(home:Scaffold(body:SingleChildScrollView(child:StripDesigner(effect:{'program':stripPreset(0),'default_pixels':3,'default_fps':20},enabled:false,changed:(_)=>changed=true))))); + await tester.tap(find.text('Fade'));expect(changed,isFalse); + }); +} From 0e0133d039aa136e9653fccfaf80aa8fcf6b82be Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:32:40 +0330 Subject: [PATCH 11/29] fix: compare numeric strip preview phase without integer-only getter --- mobile/lib/unified/strip_designer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/unified/strip_designer.dart b/mobile/lib/unified/strip_designer.dart index ca45be0f..b6ebaa22 100644 --- a/mobile/lib/unified/strip_designer.dart +++ b/mobile/lib/unified/strip_designer.dart @@ -38,7 +38,7 @@ List stripPreview(Json p,int count,int at) { final period=p['period_ms'] as int,time=at%(p['period_ms'] as int); Color color(String key,int intensity){final c=object(p[key]);int channel(String k)=>((c[k] as int? ?? 0)*intensity~/255).clamp(0,255);return Color.fromARGB(255,channel('red'),channel('green'),channel('blue'));} return List.generate(count,(pixel){ - if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase.isEven?255:(p['dim_intensity'] as int? ?? 0));} + if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase % 2 == 0 ? 255 :(p['dim_intensity'] as int? ?? 0));} if(p['primitive']=='envelope'){final points=objects(p['envelope']);var level=0;for(var i=0;i0){final previous=points[i-1],start=previous['at_ms'] as int,a=previous['intensity'] as int,b=points[i]['intensity'] as int;level=a+(b-a)*(time-start)~/(end-start);}break;}level=points[i]['intensity'] as int;}return color('primary',level);} final head=time*((count-1)~/2+1)~/period; final configured=p['tail_pixels'] as int? ?? 0,tail=math.max(3,configured==0?count~/12:configured); From 2c4fe6492e353292bcafdcaf2b983bae0dafa6e7 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:35:34 +0330 Subject: [PATCH 12/29] feat: expose strip lighting creation from the effect library --- mobile/lib/main.dart | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 66f8838f..3a58c8de 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,6 +20,7 @@ import 'unified/relay_style.dart'; import 'pakar_reference.dart' show SevenSegmentPreview; import 'unified/transport.dart'; import 'unified/effect_editor.dart'; +import 'unified/strip_designer.dart'; import 'unified/live_widgets.dart'; import 'unified/automation.dart'; import 'unified/preferences.dart'; @@ -3712,16 +3713,27 @@ class _ControllerHomeState extends State : null, ), ], - trailing: IconButton( + trailing: PopupMenuButton( tooltip: 'New effect', icon: const Icon(Icons.add), - onPressed: () => unawaited( + itemBuilder: (_) => const [ + PopupMenuItem(value: 'sequence', child: Text('Sequence')), + PopupMenuItem(value: 'strip-stream', child: Text('Strip lighting')), + ], + onSelected: (kind) => unawaited( editEffect({ 'id': '', 'name': '', - 'category': 'Effects', - 'kind': 'sequence', - 'steps': [], + 'category': kind == 'sequence' ? 'Effects' : 'Lighting', + 'kind': kind, + if (kind == 'sequence') 'steps': [], + if (kind == 'strip-stream') ...{ + 'program': stripPreset(3), + 'default_pixels': 1, + 'default_fps': 20, + 'duration_ms': 5000, + 'engine': 'host-stream', + }, }), ), ), From 65f4584b52efd25ee94b0e08e25c166fb88a084f Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:39:27 +0330 Subject: [PATCH 13/29] test: expect supported strip effect command --- mobile/test/go_tools_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/test/go_tools_test.dart b/mobile/test/go_tools_test.dart index 17cd813b..de682340 100644 --- a/mobile/test/go_tools_test.dart +++ b/mobile/test/go_tools_test.dart @@ -121,7 +121,7 @@ void main() { await settle(tester); expect( object(calls.last['params'])['command'], - 'strip effect play "warm-pulse" 100 20', + 'effect play "warm-pulse" 100 20', ); await tester.enterText(find.byKey(const ValueKey('Pixels')), '50'); await tester.pump(); From 1275716444bce18bc0a8e3fdce03ba98cf855f16 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:41:57 +0330 Subject: [PATCH 14/29] fix: align Flutter flash preview with host phase --- mobile/lib/unified/strip_designer.dart | 2 +- mobile/test/strip_designer_test.dart | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/lib/unified/strip_designer.dart b/mobile/lib/unified/strip_designer.dart index b6ebaa22..a548bed9 100644 --- a/mobile/lib/unified/strip_designer.dart +++ b/mobile/lib/unified/strip_designer.dart @@ -38,7 +38,7 @@ List stripPreview(Json p,int count,int at) { final period=p['period_ms'] as int,time=at%(p['period_ms'] as int); Color color(String key,int intensity){final c=object(p[key]);int channel(String k)=>((c[k] as int? ?? 0)*intensity~/255).clamp(0,255);return Color.fromARGB(255,channel('red'),channel('green'),channel('blue'));} return List.generate(count,(pixel){ - if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase % 2 == 0 ? 255 :(p['dim_intensity'] as int? ?? 0));} + if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(at~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase % 2 == 0 ? 255 :(p['dim_intensity'] as int? ?? 0));} if(p['primitive']=='envelope'){final points=objects(p['envelope']);var level=0;for(var i=0;i0){final previous=points[i-1],start=previous['at_ms'] as int,a=previous['intensity'] as int,b=points[i]['intensity'] as int;level=a+(b-a)*(time-start)~/(end-start);}break;}level=points[i]['intensity'] as int;}return color('primary',level);} final head=time*((count-1)~/2+1)~/period; final configured=p['tail_pixels'] as int? ?? 0,tail=math.max(3,configured==0?count~/12:configured); diff --git a/mobile/test/strip_designer_test.dart b/mobile/test/strip_designer_test.dart index 981a5b05..606d5d07 100644 --- a/mobile/test/strip_designer_test.dart +++ b/mobile/test/strip_designer_test.dart @@ -4,6 +4,10 @@ import 'package:pccontroller_mobile/unified/strip_designer.dart'; import 'package:pccontroller_mobile/unified/pixel_diagnostic.dart'; void main(){ + test('flash preview follows host step cycle for indivisible periods', () { + final p = stripPreset(0)..['period_ms'] = 850; + expect(stripPreview(p, 3, 900).first, const Color.fromARGB(255, 36, 0, 0)); + }); test('all presets support three physical pixels',(){for(var i=0;i<5;i++){final p=stripPreset(i);expect(stripProgramError(p),isNull);expect(stripPreview(p,3,1000),hasLength(3));}}); test('invalid envelope is retained and cannot preview',(){final p=stripPreset(3);p['envelope'][1]['at_ms']=0;expect(stripProgramError(p),isNotNull);expect(stripPreview(p,3,0),isEmpty);expect(p['envelope'][1]['at_ms'],0);}); test('fade and pulse timing differ',(){expect(stripPreview(stripPreset(3),3,500),isNot(stripPreview(stripPreset(4),3,500)));}); From 83991c5579fd2988e774850e74065338dfd11b70 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:35:49 +0330 Subject: [PATCH 15/29] fix: fence PWM readback during prepared playback --- .../internal/control/media_playback.go | 17 ++++ .../internal/control/media_timeline.go | 38 ++++++-- .../internal/control/media_timeline_test.go | 88 +++++++++++++++++++ Tools/Controller/internal/control/runtime.go | 8 ++ .../internal/ipcjson/pwm_channels.go | 7 ++ Tools/Controller/internal/link/session.go | 13 +++ docs/STRIP-WORKSPACE-INTEGRATION.md | 25 ++++++ 7 files changed, 189 insertions(+), 7 deletions(-) diff --git a/Tools/Controller/internal/control/media_playback.go b/Tools/Controller/internal/control/media_playback.go index 6b5cb6b0..b16111ad 100644 --- a/Tools/Controller/internal/control/media_playback.go +++ b/Tools/Controller/internal/control/media_playback.go @@ -155,6 +155,23 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla state.mu.Unlock() return runtime.MediaPlayback(), errors.New("stale media playback sequence") } + if value.Playing && value.PlanRevision != 0 && !previous.Playing { + // Fence queued/new PWM reads before checking the non-preemptible one. + // Never acknowledge Play while a previously sent read owns the slot. + runtime.mediaTimeline.readbackReserved.Store(true) + runtime.mu.RLock() + session := runtime.session + runtime.mu.RUnlock() + if session != nil && session.HasPendingRequest(native.OpPWMGet) { + runtime.mediaTimeline.readbackReserved.Store(false) + state.mu.Unlock() + return runtime.MediaPlayback(), &ResourceBusyError{Resource: "serial_readback", Owner: "pwm_readback", + Message: "PWM readback is still in flight; playback was not started"} + } + } + if !value.Playing { + runtime.mediaTimeline.readbackReserved.Store(false) + } received := time.Now() clockTiming := mediaClockTiming(previous, value, state.received, received) state.received = received diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index 53826e95..ff067060 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -13,6 +13,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "pccontroller.local/controller/internal/appconfig" @@ -99,16 +100,38 @@ type mediaTimelineStep struct { payload []byte } type mediaTimelineState struct { - operation sync.Mutex - mu sync.Mutex - status MediaTimelineStatus - plan MediaTimelinePlan - steps []mediaTimelineStep - cancel context.CancelFunc - done chan struct{} + readbackReserved atomic.Bool + operation sync.Mutex + mu sync.Mutex + status MediaTimelineStatus + plan MediaTimelinePlan + steps []mediaTimelineStep + cancel context.CancelFunc + done chan struct{} } type mediaTimelineContextKey struct{} +// PWM observations already arrive as complete changed-only frames. A fresh +// readback must not occupy the wire across a prepared cue's deadline. Check the +// accepted clock as well as worker state, including the resume/pause boundary. +func (runtime *Runtime) rejectTimelinePWMReadback() error { + if runtime.mediaTimeline.readbackReserved.Load() { + return timelinePWMReadbackBusy() + } + plan := runtime.MediaTimeline() + clock, _, _ := runtime.mediaTimelineClock() + if plan.State == "playing" || (plan.StepCount > 0 && clock.Playing && + clock.ClientID == plan.ClientID && clock.PlanRevision == plan.Revision) { + return timelinePWMReadbackBusy() + } + return nil +} + +func timelinePWMReadbackBusy() error { + return &ResourceBusyError{Resource: "serial_readback", Owner: "media_timeline", + Message: "pause prepared playback before requesting fresh PWM readback; use published PWM observations while playing"} +} + func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode byte) error { if ctx.Value(mediaTimelineContextKey{}) != nil { return nil @@ -418,6 +441,7 @@ func (runtime *Runtime) stopMediaTimeline() { state.status.State = "stopped" } state.mu.Unlock() + state.readbackReserved.Store(false) } func (runtime *Runtime) mediaTimelineOff(generation uint64) error { // Pin cleanup to this board too. Never switch off a replacement board. diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index 200cf717..ea0b5e9a 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -1,6 +1,7 @@ package control import ( + "context" "errors" "pccontroller.local/controller/internal/appconfig" "pccontroller.local/controller/internal/link" @@ -12,6 +13,93 @@ import ( "time" ) +func TestMediaTimelinePWMReadbackCannotBlockPreparedEdges(t *testing.T) { + runtime, frames, mu := mediaTimelineFixture(t, false) + value := armTimeline(t, runtime, 0) + if err := runtime.rejectTimelinePWMReadback(); err != nil { + t.Fatalf("paused readback rejected: %v", err) + } + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _, err := runtime.Request(ctx, native.OpPWMGet, nil, native.OpPWMValues) + cancel() + var busy *ResourceBusyError + if !errors.As(err, &busy) || busy.Resource != "serial_readback" { + t.Fatalf("fresh readback should report ownership conflict, got %v", err) + } + } + status := waitTimeline(t, runtime, func(status MediaTimelineStatus) bool { return status.Acknowledged == 2 }) + if status.State == "faulted" { + t.Fatal(status.Error) + } + mu.Lock() + for _, frame := range *frames { + if frame.Opcode == native.OpPWMGet { + mu.Unlock() + t.Fatal("PWM poll reached the wire during playback") + } + } + mu.Unlock() + value.Playing = false + value.Sequence++ + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatal(err) + } + if err := runtime.rejectTimelinePWMReadback(); err != nil { + t.Fatalf("pause did not release readback: %v", err) + } +} + +func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T) { + runtime, frames, mu := mediaTimelineFixture(t, false) + value := armTimeline(t, runtime, 0) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + // This fixture ACKs commands, but deliberately does not provide PWM + // values, leaving the read registered until its caller cancels it. + _, err := runtime.Request(ctx, native.OpPWMGet, nil, native.OpPWMValues) + done <- err + }() + runtime.mu.RLock() + session := runtime.session + runtime.mu.RUnlock() + deadline := time.Now().Add(time.Second) + written := false + for !written && time.Now().Before(deadline) { + mu.Lock() + for _, frame := range *frames { + written = written || frame.Opcode == native.OpPWMGet + } + mu.Unlock() + time.Sleep(time.Millisecond) + } + if !written || !session.HasPendingRequest(native.OpPWMGet) { + t.Fatal("PWM read did not acquire the wire") + } + _, err := runtime.UpdateMediaPlayback(value) + var busy *ResourceBusyError + if !errors.As(err, &busy) || busy.Owner != "pwm_readback" { + t.Fatalf("Play falsely accepted an occupied serial slot: %v", err) + } + if runtime.MediaPlayback().Playing || runtime.mediaTimeline.readbackReserved.Load() { + t.Fatal("rejected Play changed the accepted clock or stranded admission") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("readback did not release its request slot") + } + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatalf("released readback still blocks explicit Play: %v", err) + } +} + func TestMediaTimelineReportsStandaloneStripAsRetryableResourceBusy(t *testing.T) { runtime := New(Options{}) defer runtime.Close() diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index b3f7c8e3..9adc890b 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -1962,6 +1962,14 @@ func (runtime *Runtime) requestAtGenerationPriority( return native.Frame{}, err } defer segmentUnlock() + if opcode == native.OpPWMGet { + if err := runtime.rejectTimelinePWMReadback(); err != nil { + return native.Frame{}, err + } + // A request admitted while paused may wait behind another transaction. + // Recheck immediately before writing so it cannot leak into playback. + ctx = link.WithRequestWriteGuard(ctx, runtime.rejectTimelinePWMReadback) + } if err := runtime.rejectMediaTimelineConflict(ctx, opcode); err != nil { return native.Frame{}, err } diff --git a/Tools/Controller/internal/ipcjson/pwm_channels.go b/Tools/Controller/internal/ipcjson/pwm_channels.go index c1d314d6..ca4ebf9e 100644 --- a/Tools/Controller/internal/ipcjson/pwm_channels.go +++ b/Tools/Controller/internal/ipcjson/pwm_channels.go @@ -36,6 +36,13 @@ type pwmValues struct { } func (service *Service) pwmValues(ctx context.Context) (pwmValues, error) { + if service.Client.MediaTimeline().State == "playing" { + // Preserve the observation's generation, revision and original time. + // Playback must not create polling traffic or pretend this is a new ACK. + if values := service.cachedPWMValues(); values != nil { + return *values, nil + } + } ctx, cancel := context.WithTimeout(ctx, pwmOperationTimeout) defer cancel() values, err := service.Client.PWMValues(ctx) diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 78e08023..3b14452a 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -437,6 +437,19 @@ func (s *Session) Request( return s.RequestWithPriority(ctx, RequestPriorityNormal, opcode, payload, expected...) } +// HasPendingRequest reports a registered request, including its response wait. +// Callers must separately fence new admission before using this as readiness. +func (s *Session) HasPendingRequest(opcode byte) bool { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + for _, waiter := range s.waiters { + if waiter.requestOpcode == opcode { + return true + } + } + return false +} + // RequestWithPriority preserves wire serialization while allowing timing-bound // prepared-timeline commands to overtake noncritical requests that are queued // but have not yet reached the MCU. It never preempts an in-flight request. diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index ee33682d..a4272e7e 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -35,3 +35,28 @@ this does not imply 150 physical relay cycles. Human motion acceptance is pendin The three existing strip definitions retain their stable IDs and are grouped with Fade/Pulse in Strip demos with three-pixel defaults for the current bench. Private settings backups and live logs are not included in this repository. + +## Prepared playback and readback contention + +A replay on host ci.1647 exposed a PWM_GET (0x15) response wait occupying the +serial slot for 88 ms. The strip configure cue reached dispatch about 2 ms late, +then exhausted its unchanged 50 ms deadline behind that read. Framing and CRC +counters remained unchanged: this is a separate scheduling conflict. + +Fresh PWM reads now return a structured resource conflict during prepared +playback, with a second check immediately before the wire write. The PWM status +RPC serves the existing observation during playback without changing its time, +generation or revision. Resume fences new reads before checking registered ones; +an already in-flight PWM read rejects Play without accepting a playing clock. +The read is not canceled, and no mutation is retried. Pause releases the fence. + +This deliberately does not preempt an in-flight request. A client must handle +the pre-Play resource conflict honestly; seamless resume under that contention +and the final live synchronized replay remain acceptance gates. The earlier +ProgramState contention observed on ci.1631 is not claimed fixed by this change. + +Deployment evidence must include live compiled version/source identity from +the current listener. A prior updater reported a new disk digest while an old +TUI subsequently took primary ownership. That old process was gracefully closed +with operator approval and the ci.1647 listener verified separately. A disk +artifact manifest alone does not prove which executable image owns the board. From c5ff502ccad9b540351408abcbdaca2196814c56 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:05:12 +0330 Subject: [PATCH 16/29] fix: reserve serial channel before prepared playback --- .../internal/control/board_liveness_test.go | 16 +++ .../internal/control/media_playback.go | 56 ++++++--- .../internal/control/media_timeline.go | 62 +++++----- .../internal/control/media_timeline_test.go | 116 +++++++++++++++--- .../internal/control/media_timeline_wire.go | 106 ++++++++++++++++ Tools/Controller/internal/control/runtime.go | 24 ++-- .../internal/ipcjson/pwm_channels.go | 2 +- Tools/Controller/internal/link/session.go | 60 ++++++++- .../link/timeline_reservation_test.go | 97 +++++++++++++++ docs/Prepared-Media-Timeline.md | 26 ++++ docs/STRIP-WORKSPACE-INTEGRATION.md | 26 ++-- 11 files changed, 501 insertions(+), 90 deletions(-) create mode 100644 Tools/Controller/internal/control/media_timeline_wire.go create mode 100644 Tools/Controller/internal/link/timeline_reservation_test.go diff --git a/Tools/Controller/internal/control/board_liveness_test.go b/Tools/Controller/internal/control/board_liveness_test.go index 59661020..b2511cf9 100644 --- a/Tools/Controller/internal/control/board_liveness_test.go +++ b/Tools/Controller/internal/control/board_liveness_test.go @@ -26,3 +26,19 @@ func TestBoardLivenessRequiresConsecutiveFailuresAndSuccessResetsWindow(t *testi } } } + +func TestBoardLivenessReservationIsNotAStatusFailureOrSuccess(t *testing.T) { + tracker := boardLivenessFailureTracker{failures: 1} + for i := 0; i < 10; i++ { + if tracker.observe(&ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline"}) { + t.Fatal("intentional serial reservation triggered DTR recovery") + } + } + if tracker.failures != 1 { + t.Fatal("deferred probe invented a healthy response") + } + tracker.observe(nil) + if tracker.failures != 0 { + t.Fatal("real successful probe did not restore health") + } +} diff --git a/Tools/Controller/internal/control/media_playback.go b/Tools/Controller/internal/control/media_playback.go index b16111ad..5695e3b7 100644 --- a/Tools/Controller/internal/control/media_playback.go +++ b/Tools/Controller/internal/control/media_playback.go @@ -25,6 +25,7 @@ type MediaPlaybackUpdate struct { PositionMS uint64 `json:"position_ms"` DurationMS *uint64 `json:"duration_ms,omitempty"` Playing bool `json:"playing"` + PreparePlay bool `json:"prepare_play"` Loaded bool `json:"loaded"` Rate float64 `json:"rate"` Epoch uint64 `json:"epoch,omitempty"` @@ -90,6 +91,9 @@ func (value MediaPlaybackUpdate) validate() error { if value.Playing && !value.Loaded { return errors.New("unloaded media cannot be playing") } + if value.PreparePlay && (value.Playing || !value.Loaded || value.PlanRevision == 0) { + return errors.New("prepare_play requires paused loaded media and a prepared plan revision") + } return nil } func copyMediaUpdate(value MediaPlaybackUpdate) MediaPlaybackUpdate { @@ -130,9 +134,15 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla if plan.ClientID != value.ClientID || plan.Revision != value.PlanRevision { return runtime.MediaPlayback(), errors.New("media clock requires the acknowledged prepared timeline revision") } - if value.Playing && plan.StepCount > 0 && (plan.State == "faulted" || plan.State == "stopped" || plan.ArmedEpoch != value.Epoch || plan.ClockSequence == 0) { + if value.Playing && plan.StepCount > 0 && plan.State == "faulted" { + return runtime.MediaPlayback(), fmt.Errorf("prepared hardware timeline fault: %s", plan.Error) + } + if value.Playing && plan.StepCount > 0 && (plan.State == "stopped" || plan.ArmedEpoch != value.Epoch || plan.ClockSequence == 0) { return runtime.MediaPlayback(), errors.New("hardware timeline is not armed for this media epoch; pause and reprepare") } + if value.Playing && plan.StepCount > 0 && !plan.PlayReady { + return runtime.MediaPlayback(), errors.New("prepare_play must reserve the paused hardware timeline before starting playback") + } } else if value.Playing { plan := runtime.MediaTimeline() if plan.ClientID == value.ClientID && plan.StepCount > 0 && plan.State != "stopped" { @@ -155,22 +165,10 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla state.mu.Unlock() return runtime.MediaPlayback(), errors.New("stale media playback sequence") } - if value.Playing && value.PlanRevision != 0 && !previous.Playing { - // Fence queued/new PWM reads before checking the non-preemptible one. - // Never acknowledge Play while a previously sent read owns the slot. - runtime.mediaTimeline.readbackReserved.Store(true) - runtime.mu.RLock() - session := runtime.session - runtime.mu.RUnlock() - if session != nil && session.HasPendingRequest(native.OpPWMGet) { - runtime.mediaTimeline.readbackReserved.Store(false) - state.mu.Unlock() - return runtime.MediaPlayback(), &ResourceBusyError{Resource: "serial_readback", Owner: "pwm_readback", - Message: "PWM readback is still in flight; playback was not started"} - } - } - if !value.Playing { - runtime.mediaTimeline.readbackReserved.Store(false) + if value.Playing { + runtime.mediaTimeline.mu.Lock() + runtime.mediaTimeline.wireRenewedAt = time.Now() + runtime.mediaTimeline.mu.Unlock() } received := time.Now() clockTiming := mediaClockTiming(previous, value, state.received, received) @@ -217,7 +215,7 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla plan := runtime.MediaTimeline() if plan.ClientID == value.ClientID && plan.Revision == value.PlanRevision && plan.StepCount > 0 && !(fresh && mediaTimelinePausedHeartbeatArmed(plan, previous.MediaPlaybackUpdate, value)) { - deadline := time.NewTimer(mediaTimelineArmAckTimeout) + deadline := time.NewTimer(time.Until(received.Add(mediaTimelineArmAckTimeout))) poll := time.NewTicker(time.Millisecond) defer deadline.Stop() defer poll.Stop() @@ -240,6 +238,19 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla } } } + if !value.Playing { + plan := runtime.MediaTimeline() + if value.PreparePlay && plan.StepCount > 0 { + ctx, cancel := context.WithDeadline(context.Background(), received.Add(mediaTimelineArmAckTimeout)) + err := runtime.reserveMediaTimelineWire(ctx, value) + cancel() + if err != nil { + return runtime.MediaPlayback(), fmt.Errorf("paused serial reservation not ready: %w", err) + } + } else { + runtime.releaseMediaTimelineWire() + } + } return runtime.MediaPlayback(), nil } @@ -331,6 +342,15 @@ func (runtime *Runtime) runMediaPlayback() { _, _ = runtime.SetProgramState("media-playback", mode, "client playback") claimed = playing } + if runtime.MediaTimeline().PlayReady { + // Presentation cannot compete with actuator deadlines. Keep the last + // observed display receipt and label it deferred, never synchronized. + state.mu.Lock() + state.snapshot.BoardSynced = false + state.snapshot.BoardError = "board clock display deferred during prepared playback" + state.mu.Unlock() + continue + } runtime.mu.RLock() connected, generation, capabilities := runtime.session != nil, runtime.generation, runtime.hello.Capabilities runtime.mu.RUnlock() diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index ff067060..a0c4096d 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -13,7 +13,6 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" "pccontroller.local/controller/internal/appconfig" @@ -40,6 +39,7 @@ type MediaTimelinePlan struct { MaxLatenessMS uint32 `json:"max_lateness_ms"` } type MediaTimelineStatus struct { + PlayReady bool `json:"play_ready"` ClientID string `json:"client_id"` Revision uint64 `json:"revision"` Hash string `json:"hash,omitempty"` @@ -100,38 +100,18 @@ type mediaTimelineStep struct { payload []byte } type mediaTimelineState struct { - readbackReserved atomic.Bool - operation sync.Mutex - mu sync.Mutex - status MediaTimelineStatus - plan MediaTimelinePlan - steps []mediaTimelineStep - cancel context.CancelFunc - done chan struct{} + releaseWire func() + wireRenewedAt time.Time + operation sync.Mutex + mu sync.Mutex + status MediaTimelineStatus + plan MediaTimelinePlan + steps []mediaTimelineStep + cancel context.CancelFunc + done chan struct{} } type mediaTimelineContextKey struct{} -// PWM observations already arrive as complete changed-only frames. A fresh -// readback must not occupy the wire across a prepared cue's deadline. Check the -// accepted clock as well as worker state, including the resume/pause boundary. -func (runtime *Runtime) rejectTimelinePWMReadback() error { - if runtime.mediaTimeline.readbackReserved.Load() { - return timelinePWMReadbackBusy() - } - plan := runtime.MediaTimeline() - clock, _, _ := runtime.mediaTimelineClock() - if plan.State == "playing" || (plan.StepCount > 0 && clock.Playing && - clock.ClientID == plan.ClientID && clock.PlanRevision == plan.Revision) { - return timelinePWMReadbackBusy() - } - return nil -} - -func timelinePWMReadbackBusy() error { - return &ResourceBusyError{Resource: "serial_readback", Owner: "media_timeline", - Message: "pause prepared playback before requesting fresh PWM readback; use published PWM observations while playing"} -} - func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode byte) error { if ctx.Value(mediaTimelineContextKey{}) != nil { return nil @@ -146,7 +126,11 @@ func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode func (runtime *Runtime) MediaTimeline() MediaTimelineStatus { runtime.mediaTimeline.mu.Lock() defer runtime.mediaTimeline.mu.Unlock() - return runtime.mediaTimeline.status + status := runtime.mediaTimeline.status + if status.State == "paused" && time.Since(runtime.mediaTimeline.wireRenewedAt) > mediaTimelineArmAckTimeout { + status.PlayReady = false + } + return status } func compileMediaTimeline(plan MediaTimelinePlan, runner *MacroRunner) ([]mediaTimelineStep, error) { @@ -441,7 +425,7 @@ func (runtime *Runtime) stopMediaTimeline() { state.status.State = "stopped" } state.mu.Unlock() - state.readbackReserved.Store(false) + runtime.releaseMediaTimelineWire() } func (runtime *Runtime) mediaTimelineOff(generation uint64) error { // Pin cleanup to this board too. Never switch off a replacement board. @@ -513,6 +497,7 @@ func mediaTimelineRestore(steps []mediaTimelineStep, index int) []mediaTimelineS } func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{}, plan MediaTimelinePlan, steps []mediaTimelineStep, generation uint64) { defer close(done) + defer runtime.releaseMediaTimelineWire() ticker := time.NewTicker(2 * time.Millisecond) defer ticker.Stop() index := 0 @@ -520,6 +505,7 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} wasPlaying := false owned := false lastWake := time.Now() + var lastPresentation time.Time defer func() { if owned { if err := runtime.mediaTimelineOff(generation); err != nil { @@ -570,6 +556,7 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} fault("video clock feedback expired; seek before the pending cue and reprepare") return } + runtime.expirePausedMediaTimelineWire() continue } if runtime.emergencyStop.Load() { @@ -707,5 +694,16 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} index++ position = actual } + // Bound optional maintenance to 50 ms in gaps of at least 250 ms. + // The original cue timestamps and lateness checks remain unchanged. + currentPosition := float64(clock.PositionMS) + time.Since(anchor).Seconds()*1000*clock.Rate + gapMS := math.Inf(1) + if index < len(steps) { + gapMS = (steps[index].dueMS - currentPosition) / clock.Rate + } + if gapMS >= 250 && time.Since(lastPresentation) >= 2*time.Second { + runtime.serviceTimelinePresentation(ctx, clock, anchor, generation) + lastPresentation = time.Now() + } } } diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index ea0b5e9a..e50751c2 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -13,21 +13,21 @@ import ( "time" ) -func TestMediaTimelinePWMReadbackCannotBlockPreparedEdges(t *testing.T) { +func TestMediaTimelineBackgroundTrafficCannotBlockPreparedEdges(t *testing.T) { runtime, frames, mu := mediaTimelineFixture(t, false) value := armTimeline(t, runtime, 0) - if err := runtime.rejectTimelinePWMReadback(); err != nil { - t.Fatalf("paused readback rejected: %v", err) - } if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } - for i := 0; i < 10; i++ { + for _, opcode := range []byte{native.OpPWMGet, native.OpGetStatus, native.OpDisplayText, native.OpProgramState} { ctx, cancel := context.WithTimeout(context.Background(), time.Second) - _, err := runtime.Request(ctx, native.OpPWMGet, nil, native.OpPWMValues) + // Internal presentation bypasses the live-output conflict check, but + // still must not bypass the actual wire reservation. + ctx = context.WithValue(ctx, mediaTimelineContextKey{}, true) + _, err := runtime.Request(ctx, opcode, nil, native.OpACK) cancel() var busy *ResourceBusyError - if !errors.As(err, &busy) || busy.Resource != "serial_readback" { + if !errors.As(err, &busy) || busy.Resource != "serial_channel" { t.Fatalf("fresh readback should report ownership conflict, got %v", err) } } @@ -48,14 +48,38 @@ func TestMediaTimelinePWMReadbackCannotBlockPreparedEdges(t *testing.T) { if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } - if err := runtime.rejectTimelinePWMReadback(); err != nil { - t.Fatalf("pause did not release readback: %v", err) + if runtime.MediaTimeline().PlayReady { + t.Fatal("pause did not release readback") + } +} + +func TestMediaTimelineReservationLeaseUsesGrantTimeNotOldClockReceipt(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + _ = armTimeline(t, runtime, 0) + runtime.mediaPlayback.mu.Lock() + runtime.mediaPlayback.received = time.Now().Add(-300 * time.Millisecond) + runtime.mediaPlayback.mu.Unlock() + runtime.expirePausedMediaTimelineWire() + if !runtime.MediaTimeline().PlayReady { + t.Fatal("old paused clock receipt prematurely expired newly granted reservation") + } + runtime.mediaTimeline.mu.Lock() + runtime.mediaTimeline.wireRenewedAt = time.Now().Add(-300 * time.Millisecond) + runtime.mediaTimeline.mu.Unlock() + runtime.expirePausedMediaTimelineWire() + if runtime.MediaTimeline().PlayReady { + t.Fatal("expired reservation still advertised readiness") } } func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T) { runtime, frames, mu := mediaTimelineFixture(t, false) value := armTimeline(t, runtime, 0) + value.Playing = false + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatal(err) + } + value.Sequence++ ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) @@ -81,13 +105,16 @@ func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T if !written || !session.HasPendingRequest(native.OpPWMGet) { t.Fatal("PWM read did not acquire the wire") } - _, err := runtime.UpdateMediaPlayback(value) - var busy *ResourceBusyError - if !errors.As(err, &busy) || busy.Owner != "pwm_readback" { - t.Fatalf("Play falsely accepted an occupied serial slot: %v", err) + value.PreparePlay = true + armed := make(chan error, 1) + go func() { _, err := runtime.UpdateMediaPlayback(value); armed <- err }() + select { + case err := <-armed: + t.Fatalf("reservation returned before read drained: %v", err) + case <-time.After(20 * time.Millisecond): } - if runtime.MediaPlayback().Playing || runtime.mediaTimeline.readbackReserved.Load() { - t.Fatal("rejected Play changed the accepted clock or stranded admission") + if runtime.MediaPlayback().Playing || runtime.MediaTimeline().PlayReady { + t.Fatal("draining reservation falsely acknowledged readiness or Play") } cancel() select { @@ -95,6 +122,20 @@ func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T case <-time.After(time.Second): t.Fatal("readback did not release its request slot") } + select { + case err := <-armed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("reservation did not finish") + } + if !runtime.MediaTimeline().PlayReady { + t.Fatal("drained reservation not acknowledged") + } + value.PreparePlay = false + value.Playing = true + value.Sequence++ if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatalf("released readback still blocks explicit Play: %v", err) } @@ -114,6 +155,43 @@ func TestMediaTimelineReportsStandaloneStripAsRetryableResourceBusy(t *testing.T } } +func TestMediaTimelinePausedReservationExpiresAndRequiresNewPreparePlay(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + value := armTimeline(t, runtime, 0) + if !runtime.MediaTimeline().PlayReady { + t.Fatal("paused reservation missing") + } + waitTimeline(t, runtime, func(status MediaTimelineStatus) bool { return !status.PlayReady }) + if _, err := runtime.UpdateMediaPlayback(value); err == nil { + t.Fatal("expired serial reservation accepted Play") + } +} + +func TestMediaTimelineEmergencyStopReleasesPausedReservation(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + _ = armTimeline(t, runtime, 0) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := runtime.SetEmergencyStop(ctx, true, "test", "reservation regression"); err != nil { + t.Fatal(err) + } + if runtime.MediaTimeline().PlayReady { + t.Fatal("E-STOP left wire reserved") + } +} + +func TestMediaTimelinePreparePlayValidation(t *testing.T) { + for _, value := range []MediaPlaybackUpdate{ + {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: false, PlanRevision: 1}, + {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: true, PlanRevision: 0}, + {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: true, Playing: true, PlanRevision: 1}, + } { + if err := value.validate(); err == nil { + t.Fatalf("invalid preparation accepted: %+v", value) + } + } +} + func TestMediaTimelineCompilerFreezesOffsetsAndRejectsInvalidPlans(t *testing.T) { plan := MediaTimelinePlan{ClientID: "test", Revision: 1, Actions: []MediaTimelineAction{ {ID: "on", TimeMS: 10000, Step: appconfig.MacroStep{Kind: "relay", Target: 7, Value: 1}}, @@ -257,11 +335,13 @@ func armTimeline(t *testing.T, runtime *Runtime, at uint64) MediaPlaybackUpdate } value.Playing = false value.PositionMS = at + value.PreparePlay = true if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } value.Sequence++ value.Playing = true + value.PreparePlay = false return value } func TestMediaTimelineExecutesBothEdgesWithoutUIOrRepeatedCueRPC(t *testing.T) { @@ -433,8 +513,8 @@ func TestMediaTimelineClockLeaseFaultsInsteadOfSkippingPendingCue(t *testing.T) if status.Acknowledged != 2 { t.Fatalf("unexpected ACK ledger: %+v", status) } - if _, err := runtime.UpdateMediaPlayback(MediaPlaybackUpdate{ClientID: "test", Sequence: 3, Loaded: true, Playing: true, Rate: 1, Epoch: 1, PlanRevision: 1}); err == nil { - t.Fatal("faulted plan resumed") + if _, err := runtime.UpdateMediaPlayback(MediaPlaybackUpdate{ClientID: "test", Sequence: 3, Loaded: true, Playing: true, Rate: 1, Epoch: 1, PlanRevision: 1}); err == nil || !strings.Contains(err.Error(), status.Error) { + t.Fatalf("faulted plan resumed or its original error was masked: %v", err) } } @@ -448,11 +528,13 @@ func TestMediaTimelinePauseResumeRestoresLatchedValueAndSeekRequiresArming(t *te value.Sequence++ value.Playing = false value.PositionMS = 100 + value.PreparePlay = true if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } value.Sequence++ value.Playing = true + value.PreparePlay = false if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } diff --git a/Tools/Controller/internal/control/media_timeline_wire.go b/Tools/Controller/internal/control/media_timeline_wire.go new file mode 100644 index 00000000..f9dfcf0a --- /dev/null +++ b/Tools/Controller/internal/control/media_timeline_wire.go @@ -0,0 +1,106 @@ +package control + +import ( + "context" + "errors" + "fmt" + "time" + + "pccontroller.local/controller/internal/link" + "pccontroller.local/controller/internal/native" +) + +func (runtime *Runtime) releaseMediaTimelineWire() { + state := &runtime.mediaTimeline + state.mu.Lock() + release := state.releaseWire + state.releaseWire = nil + state.wireRenewedAt = time.Time{} + state.status.PlayReady = false + state.mu.Unlock() + if release != nil { + release() + } +} + +func (runtime *Runtime) expirePausedMediaTimelineWire() { + state := &runtime.mediaTimeline + state.mu.Lock() + if state.releaseWire == nil || time.Since(state.wireRenewedAt) <= mediaTimelineArmAckTimeout { + state.mu.Unlock() + return + } + release := state.releaseWire + state.releaseWire = nil + state.wireRenewedAt = time.Time{} + state.status.PlayReady = false + state.mu.Unlock() + release() +} + +// The timeline owns maintenance too. Refresh host presence/display only in a +// large cue-free gap; never let a separate presentation worker occupy the wire +// across a cue. Dense timelines themselves keep firmware host activity alive. +func (runtime *Runtime) serviceTimelinePresentation(ctx context.Context, value MediaPlaybackUpdate, anchor time.Time, generation uint64) { + ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + _, err := runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpProgramState, native.ProgramStatePayload(true), native.OpACK) + if err == nil { + _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpDisplayText, mediaDisplayPayload(value, time.Since(anchor)), native.OpACK) + } + state := &runtime.mediaPlayback + state.mu.Lock() + defer state.mu.Unlock() + state.snapshot.BoardSynced = err == nil + if err == nil { + state.snapshot.BoardSyncedAt = time.Now().UTC() + state.snapshot.BoardSequence = value.Sequence + state.snapshot.BoardError = "" + } else { + state.snapshot.BoardError = fmt.Sprintf("timeline presentation not acknowledged: %v", err) + } +} + +// Called only after the executor acknowledged the paused epoch. Media playback +// operations serialize this with other clock updates; terminal executor state +// is rechecked after draining, because cancellation can happen concurrently. +func (runtime *Runtime) reserveMediaTimelineWire(ctx context.Context, value MediaPlaybackUpdate) error { + state := &runtime.mediaTimeline + state.mu.Lock() + if state.releaseWire != nil && state.status.PlayReady { + state.wireRenewedAt = time.Now() + state.mu.Unlock() + return nil + } + generation := state.status.Generation + state.mu.Unlock() + runtime.mu.RLock() + session := runtime.session + valid := session != nil && runtime.generation == generation && runtime.sessionUsableLocked() + runtime.mu.RUnlock() + if !valid { + return errors.New("prepared board session changed before serial reservation") + } + release, err := session.ReserveTimeline(ctx) + if err != nil { + return err + } + runtime.mu.RLock() + current := runtime.session == session && runtime.generation == generation && runtime.sessionUsableLocked() + runtime.mu.RUnlock() + state.mu.Lock() + status := state.status + if status.ClientID != value.ClientID || status.Revision != value.PlanRevision || + status.ArmedEpoch != value.Epoch || status.State != "paused" || runtime.emergencyStop.Load() || !current { + state.mu.Unlock() + release() + return errors.New("prepared paused epoch changed while reserving serial channel") + } + state.releaseWire = release + state.wireRenewedAt = time.Now() + state.status.PlayReady = true + state.mu.Unlock() + return nil +} diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 9adc890b..5f6d0abf 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -1962,14 +1962,6 @@ func (runtime *Runtime) requestAtGenerationPriority( return native.Frame{}, err } defer segmentUnlock() - if opcode == native.OpPWMGet { - if err := runtime.rejectTimelinePWMReadback(); err != nil { - return native.Frame{}, err - } - // A request admitted while paused may wait behind another transaction. - // Recheck immediately before writing so it cannot leak into playback. - ctx = link.WithRequestWriteGuard(ctx, runtime.rejectTimelinePWMReadback) - } if err := runtime.rejectMediaTimelineConflict(ctx, opcode); err != nil { return native.Frame{}, err } @@ -2004,6 +1996,10 @@ func (runtime *Runtime) requestAtGenerationPriority( } } frame, err := session.RequestWithPriority(ctx, priority, opcode, payload, expected...) + if errors.Is(err, link.ErrTimelineReserved) { + return native.Frame{}, &ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline", + Message: "serial channel reserved for prepared playback; pause before issuing live commands"} + } if err != nil { return native.Frame{}, err } @@ -2901,6 +2897,12 @@ func (runtime *Runtime) syncProgramState(snapshot ProgramStateSnapshot, lifecycl err := runtime.Command(ctx, native.OpProgramState, payload) cancel() if err != nil { + var busy *ResourceBusyError + if errors.As(err, &busy) && busy.Resource == "serial_channel" { + runtime.publishEvent(Event{Kind: "program.state.sync", Lifecycle: "deferred", + State: string(snapshot.Mode), Reason: err.Error(), Text: "program state presentation deferred by prepared playback"}) + return + } runtime.publishEvent(Event{ Kind: "program.state.sync", Lifecycle: "failed", State: string(snapshot.Mode), Reason: err.Error(), @@ -3004,6 +3006,12 @@ type boardLivenessFailureTracker struct { } func (tracker *boardLivenessFailureTracker) observe(err error) bool { + var busy *ResourceBusyError + if errors.As(err, &busy) && busy.Resource == "serial_channel" && busy.Owner == "media_timeline" { + // No probe went on the wire. Neither diagnose a lost board nor invent + // a successful liveness response from intentional admission deferral. + return false + } if err == nil { tracker.failures = 0 return false diff --git a/Tools/Controller/internal/ipcjson/pwm_channels.go b/Tools/Controller/internal/ipcjson/pwm_channels.go index ca4ebf9e..6493e580 100644 --- a/Tools/Controller/internal/ipcjson/pwm_channels.go +++ b/Tools/Controller/internal/ipcjson/pwm_channels.go @@ -36,7 +36,7 @@ type pwmValues struct { } func (service *Service) pwmValues(ctx context.Context) (pwmValues, error) { - if service.Client.MediaTimeline().State == "playing" { + if service.Client.MediaTimeline().PlayReady { // Preserve the observation's generation, revision and original time. // Playback must not create polling traffic or pretend this is a new ACK. if values := service.cachedPWMValues(); values != nil { diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 3b14452a..5910a59f 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -29,6 +29,7 @@ var ( ErrSerialOpenPending = errors.New("serial port acquisition is still pending") ErrSequenceExhaust = errors.New("all request sequence numbers are in use") ErrControlLinesUnsupported = errors.New("transport does not support DTR/RTS") + ErrTimelineReserved = errors.New("serial channel is reserved for prepared playback") ) var openSerialPort = serial.Open @@ -103,18 +104,21 @@ const ( RequestPriorityBackground RequestPriority = iota RequestPriorityNormal RequestPriorityTimeline + RequestPrioritySafety ) type requestWaiter struct { priority RequestPriority ready chan struct{} granted bool + err error } type requestAdmission struct { - mu sync.Mutex - active bool - waiters []*requestWaiter + mu sync.Mutex + active bool + reserved bool + waiters []*requestWaiter } func (admission *requestAdmission) acquire( @@ -133,6 +137,10 @@ func (admission *requestAdmission) acquire( waiter := &requestWaiter{priority: priority, ready: make(chan struct{})} admission.mu.Lock() + if admission.reserved && priority < RequestPriorityTimeline { + admission.mu.Unlock() + return nil, ErrTimelineReserved + } if !admission.active && len(admission.waiters) == 0 { admission.active = true admission.mu.Unlock() @@ -143,6 +151,9 @@ func (admission *requestAdmission) acquire( select { case <-waiter.ready: + if waiter.err != nil { + return nil, waiter.err + } return admission.release, nil case <-ctx.Done(): admission.cancel(waiter) @@ -153,6 +164,49 @@ func (admission *requestAdmission) acquire( } } +// ReserveTimeline fences normal/background traffic before draining the current +// transaction. It never cancels bytes already sent. The returned lease permits +// timeline and safety commands until its owner releases it. +func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { + a := &s.requestAdmission + a.mu.Lock() + if a.reserved { + a.mu.Unlock() + return nil, ErrTimelineReserved + } + a.reserved = true + kept := a.waiters[:0] + for _, waiter := range a.waiters { + if waiter.priority < RequestPriorityTimeline { + waiter.err = ErrTimelineReserved + close(waiter.ready) + } else { + kept = append(kept, waiter) + } + } + a.waiters = kept + a.mu.Unlock() + var once sync.Once + release := func() { + once.Do(func() { + a.mu.Lock() + a.reserved = false + a.mu.Unlock() + }) + } + drain, err := a.acquire(ctx, s.closing, RequestPriorityTimeline) + if err != nil { + release() + return nil, err + } + drain() + if err := ctx.Err(); err != nil { + release() + return nil, err + } + return release, nil +} + func (admission *requestAdmission) cancel(waiter *requestWaiter) { admission.mu.Lock() defer admission.mu.Unlock() diff --git a/Tools/Controller/internal/link/timeline_reservation_test.go b/Tools/Controller/internal/link/timeline_reservation_test.go new file mode 100644 index 00000000..f50c0834 --- /dev/null +++ b/Tools/Controller/internal/link/timeline_reservation_test.go @@ -0,0 +1,97 @@ +package link + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestTimelineReservationDrainsActiveAndRejectsQueuedPresentation(t *testing.T) { + session := NewForPort("RESERVATION", newFakePort()) + defer session.Close() + a := &session.requestAdmission + active, err := a.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + queued := make(chan error, 1) + go func() { + release, err := a.acquire(context.Background(), session.closing, RequestPriorityNormal) + if release != nil { + release() + } + queued <- err + }() + waitForQueuedRequests(t, a, 1) + reserved := make(chan func(), 1) + failed := make(chan error, 1) + go func() { + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + failed <- err + return + } + reserved <- release + }() + select { + case err := <-queued: + if !errors.Is(err, ErrTimelineReserved) { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("queued presentation was not rejected") + } + select { + case <-reserved: + t.Fatal("reservation acknowledged before the active request drained") + case err := <-failed: + t.Fatal(err) + default: + } + active() + var releaseReservation func() + select { + case releaseReservation = <-reserved: + case err := <-failed: + t.Fatal(err) + case <-time.After(time.Second): + t.Fatal("reservation did not drain") + } + defer releaseReservation() + for _, priority := range []RequestPriority{RequestPriorityBackground, RequestPriorityNormal} { + if _, err := a.acquire(context.Background(), session.closing, priority); !errors.Is(err, ErrTimelineReserved) { + t.Fatalf("priority %d: %v", priority, err) + } + } + for _, priority := range []RequestPriority{RequestPriorityTimeline, RequestPrioritySafety} { + release, err := a.acquire(context.Background(), session.closing, priority) + if err != nil { + t.Fatal(err) + } + release() + } + releaseReservation() + release, err := a.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + release() +} + +func TestTimelineReservationTimeoutDoesNotStrandAdmission(t *testing.T) { + session := NewForPort("RESERVATION", newFakePort()) + defer session.Close() + active, _ := session.requestAdmission.acquire(context.Background(), session.closing, RequestPriorityNormal) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := session.ReserveTimeline(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + active() + release, err := session.requestAdmission.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + release() +} diff --git a/docs/Prepared-Media-Timeline.md b/docs/Prepared-Media-Timeline.md index b4cec31c..bba183ce 100644 --- a/docs/Prepared-Media-Timeline.md +++ b/docs/Prepared-Media-Timeline.md @@ -27,6 +27,32 @@ Playing against an unprepared/faulted revision or unarmed epoch is rejected. A paused update must first arm the cursor. Clock extrapolation retains a Go monotonic anchor; public UTC timestamps are not used as scheduling clocks. +Before starting a nonempty plan, send a paused, loaded update with +`prepare_play: true`. The host arms that exact revision/epoch and reserves the +serial channel within the same 250 ms arming budget, draining the current +transaction without canceling it. Wait for matching feedback with +`timeline.play_ready: true` before unpausing the media. The pending-start client +renews its paused intent at 100 ms intervals until the decoder actually starts; +only then does it publish `playing: true` and `prepare_play: false`. A paused +`prepare_play: false` cancels the reservation after output-off cleanup. Ordinary +paused arming and empty plans do not require a serial reservation. + +The paused reservation's 250 ms lease starts at its actual grant/renewal, while +the original clock receipt anchor remains unchanged. Expiry, pause, terminal +fault, plan replacement or disconnect revokes readiness. Playing without the +matching reservation is rejected. This is the current contract: deploy the +updated host and clock publisher together; an old client cannot skip it. + +While reserved, queued/new normal and background requests return a resource +conflict before writing. Timeline and safety requests remain admissible. +Intentional deferral is not a failed liveness probe and cannot cause DTR recovery. +The prepared executor owns optional ProgramState/display maintenance too: at +most every two seconds in cue-free gaps of at least 250 ms, with a shared 50 ms +request budget. Dense command streams themselves renew firmware host presence. +Display refresh is honestly deferred where no safe gap exists; its old readback +is not reported as a newly synchronized display. Actuator deadlines stay intact. +The original terminal fault is retained in later rejected playing updates. + `controller.media.timeline.get` and playback snapshot `timeline` return: revision/hash/generation/state, armed epoch/clock sequence, command count and ACK count, explicitly rebased steps, last command/deadline, last/max ACK lateness, diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index a4272e7e..abc3bd5b 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -43,17 +43,21 @@ serial slot for 88 ms. The strip configure cue reached dispatch about 2 ms late, then exhausted its unchanged 50 ms deadline behind that read. Framing and CRC counters remained unchanged: this is a separate scheduling conflict. -Fresh PWM reads now return a structured resource conflict during prepared -playback, with a second check immediately before the wire write. The PWM status -RPC serves the existing observation during playback without changing its time, -generation or revision. Resume fences new reads before checking registered ones; -an already in-flight PWM read rejects Play without accepting a playing clock. -The read is not canceled, and no mutation is retried. Pause releases the fence. - -This deliberately does not preempt an in-flight request. A client must handle -the pre-Play resource conflict honestly; seamless resume under that contention -and the final live synchronized replay remain acceptance gates. The earlier -ProgramState contention observed on ci.1631 is not claimed fixed by this change. +A subsequent ci.1648 replay exposed internal display refresh (0x38) blocking +resume after the PWM-only guard. The current solution reserves the whole serial +channel during an explicit paused `prepare_play` handshake. Pealayer must wait +for matching `play_ready` feedback before unpausing. Queued/background work is +rejected before writing; in-flight work drains within the original arming budget. +The PWM status RPC retains the observation's time, generation and revision. +Pause, cancellation, stale intent, terminal faults and disconnect release the +reservation. Deferred health probes cannot trigger DTR recovery. Optional board +presentation runs through the timeline owner only in sufficiently large gaps; +dense playback defers it honestly. See [the complete contract](Prepared-Media-Timeline.md). + +The new host and Pealayer publisher must be deployed together. Final synchronized +replay, dense/sparse playback and human physical acceptance remain open until +verified on that exact pair. Neither the PWM-only intermediate update nor the +standalone strip soak establishes synchronized playback acceptance. Deployment evidence must include live compiled version/source identity from the current listener. A prior updater reported a new disk digest while an old From af8f7ca4e90e285634f40f15c19ea18665e96fd6 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:10:47 +0330 Subject: [PATCH 17/29] fix: preserve safety access during serial reservation --- .../internal/control/emergency_stop.go | 3 +++ .../internal/control/media_timeline.go | 2 +- .../internal/control/media_timeline_test.go | 21 ++++++++++++++- .../internal/control/media_timeline_wire.go | 26 ++++++++++++++----- Tools/Controller/internal/control/runtime.go | 3 +++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Tools/Controller/internal/control/emergency_stop.go b/Tools/Controller/internal/control/emergency_stop.go index eaf95d75..8503d676 100644 --- a/Tools/Controller/internal/control/emergency_stop.go +++ b/Tools/Controller/internal/control/emergency_stop.go @@ -12,6 +12,8 @@ import ( "pccontroller.local/controller/internal/native" ) +type emergencySafetyContextKey struct{} + // EmergencyStopState is the host-authoritative, transport-neutral motion and // effect interlock. Unlocking removes the command block but never resumes work // that was cancelled when the latch engaged. @@ -97,6 +99,7 @@ func (runtime *Runtime) reassertEmergencyStop() { } func (runtime *Runtime) applyEmergencyStopTerminalState(ctx context.Context) []error { + ctx = context.WithValue(ctx, emergencySafetyContextKey{}, true) var stopErrors []error // Repeat the board-safe terminal state after cancellation. This defeats a // final in-flight macro/stream frame without ever energizing an output. diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index a0c4096d..6ed4f297 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -113,7 +113,7 @@ type mediaTimelineState struct { type mediaTimelineContextKey struct{} func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode byte) error { - if ctx.Value(mediaTimelineContextKey{}) != nil { + if ctx.Value(mediaTimelineContextKey{}) != nil || ctx.Value(emergencySafetyContextKey{}) != nil { return nil } status := runtime.MediaTimeline() diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index e50751c2..62e6ab58 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -180,6 +180,25 @@ func TestMediaTimelineEmergencyStopReleasesPausedReservation(t *testing.T) { } } +func TestMediaTimelineEmergencyStopBypassesReservationStillBeingGranted(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + runtime.mu.RLock() + session := runtime.session + runtime.mu.RUnlock() + // Model the reservation's drain phase: the link has fenced admission but + // no completed lease has yet been installed in mediaTimeline.releaseWire. + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + t.Fatal(err) + } + defer release() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := runtime.SetEmergencyStop(ctx, true, "test", "pending reservation"); err != nil { + t.Fatalf("safety cleanup was blocked by pending reservation: %v", err) + } +} + func TestMediaTimelinePreparePlayValidation(t *testing.T) { for _, value := range []MediaPlaybackUpdate{ {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: false, PlanRevision: 1}, @@ -303,7 +322,7 @@ func mediaTimelineFixture(t *testing.T, nack bool) (*Runtime, *[]native.Frame, * } } }() - runtime.attach(link.OpenResult{Session: link.NewForPort("TIMELINE-TEST", port), Port: ports.Info{Name: "TIMELINE-TEST"}, Hello: native.Hello{Name: "Virtual"}}) + runtime.attach(link.OpenResult{Session: link.NewForPort("TIMELINE-TEST", port), Port: ports.Info{Name: "TIMELINE-TEST"}, Hello: native.Hello{Name: "Virtual", Capabilities: native.CapabilityProgramState | native.CapabilityScheduledSegments}}) t.Cleanup(func() { runtime.Close() }) return runtime, &frames, &mu } diff --git a/Tools/Controller/internal/control/media_timeline_wire.go b/Tools/Controller/internal/control/media_timeline_wire.go index f9dfcf0a..e9a3181f 100644 --- a/Tools/Controller/internal/control/media_timeline_wire.go +++ b/Tools/Controller/internal/control/media_timeline_wire.go @@ -44,22 +44,36 @@ func (runtime *Runtime) expirePausedMediaTimelineWire() { func (runtime *Runtime) serviceTimelinePresentation(ctx context.Context, value MediaPlaybackUpdate, anchor time.Time, generation uint64) { ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() - _, err := runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, - native.OpProgramState, native.ProgramStatePayload(true), native.OpACK) - if err == nil { + runtime.mu.RLock() + capabilities := runtime.hello.Capabilities + runtime.mu.RUnlock() + programSupported := capabilities&native.CapabilityProgramState != 0 + displaySupported := capabilities&native.CapabilityScheduledSegments != 0 + var err error + if programSupported { + _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpProgramState, native.ProgramStatePayload(true), native.OpACK) + } + if displaySupported && err == nil { _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, native.OpDisplayText, mediaDisplayPayload(value, time.Since(anchor)), native.OpACK) } + if !programSupported && !displaySupported { + _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpGetStatus, nil, native.OpStatus) + } state := &runtime.mediaPlayback state.mu.Lock() defer state.mu.Unlock() - state.snapshot.BoardSynced = err == nil - if err == nil { + state.snapshot.BoardSynced = err == nil && displaySupported + if err == nil && displaySupported { state.snapshot.BoardSyncedAt = time.Now().UTC() state.snapshot.BoardSequence = value.Sequence state.snapshot.BoardError = "" - } else { + } else if err != nil { state.snapshot.BoardError = fmt.Sprintf("timeline presentation not acknowledged: %v", err) + } else { + state.snapshot.BoardError = "board does not advertise scheduled clock display" } } diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 5f6d0abf..5d817829 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -1990,6 +1990,9 @@ func (runtime *Runtime) requestAtGenerationPriority( session := runtime.session runtime.mu.RUnlock() traceEpoch := runtime.pwmTraceAdmission(opcode, payload) + if ctx.Value(emergencySafetyContextKey{}) != nil { + priority = link.RequestPrioritySafety + } if opcode == native.OpPWMSet { if guard, ok := ctx.Value(rfRollGuardKey{}).(func() error); ok { ctx = link.WithRequestWriteGuard(ctx, guard) From 4437f0632f7603a98a0c31422edb4774819230a3 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:41:21 +0330 Subject: [PATCH 18/29] Classify playback deferrals and retain serial timeout timing --- Tools/Controller/controller.go | 7 +++ .../internal/control/lcd_presenter.go | 6 +++ .../internal/control/lcd_presenter_test.go | 30 +++++++++++ .../internal/control/media_timeline.go | 3 +- Tools/Controller/internal/control/runtime.go | 9 +++- .../internal/link/response_timing_test.go | 51 +++++++++++++++++++ Tools/Controller/internal/link/session.go | 51 ++++++++++++++----- Tools/Controller/status_subscription_test.go | 44 ++++++++++++++++ docs/STRIP-WORKSPACE-INTEGRATION.md | 25 +++++++++ 9 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 Tools/Controller/internal/link/response_timing_test.go diff --git a/Tools/Controller/controller.go b/Tools/Controller/controller.go index 47166de3..1eed2bf2 100644 --- a/Tools/Controller/controller.go +++ b/Tools/Controller/controller.go @@ -1460,6 +1460,8 @@ func (client *Client) runStatusSubscriptionHub() { } cancel() update := StatusUpdate{Time: at, Status: status} + var busy *control.ResourceBusyError + deferred := errors.As(err, &busy) && busy.Resource == "serial_channel" && busy.Owner == "media_timeline" if err != nil { update.Error = err.Error() } @@ -1471,6 +1473,11 @@ func (client *Client) runStatusSubscriptionHub() { for !subscriber.next.After(at) { subscriber.next = subscriber.next.Add(subscriber.interval) } + if deferred { + // No query reached the board. Advance cadence without inventing a + // fresh sample or publishing a controller.error to every player. + continue + } if err != nil { backoff := at.Add(time.Second) if subscriber.next.Before(backoff) { diff --git a/Tools/Controller/internal/control/lcd_presenter.go b/Tools/Controller/internal/control/lcd_presenter.go index d9ce1b13..46eda46a 100644 --- a/Tools/Controller/internal/control/lcd_presenter.go +++ b/Tools/Controller/internal/control/lcd_presenter.go @@ -2,6 +2,7 @@ package control import ( "context" + "errors" "fmt" "strings" "sync" @@ -427,6 +428,11 @@ func (presenter *LCDPresenter) send(version uint64, line1, line2 string) { line1, line2 = lcdLine(line1), lcdLine(line2) err = presenter.runtime.Command(ctx, native.OpDisplayText, payload) if err != nil { + var busy *ResourceBusyError + if errors.As(err, &busy) && busy.Resource == "serial_channel" && busy.Owner == "media_timeline" { + presenter.runtime.PublishHostEvent("lcd.deferred", "LCD presentation deferred by prepared playback") + return + } // Keep transport failures out of the generic error priority path: feeding // them back into the presenter would recursively retry an unavailable LCD. presenter.runtime.PublishHostEvent("lcd.error", "LCD presentation: "+err.Error()) diff --git a/Tools/Controller/internal/control/lcd_presenter_test.go b/Tools/Controller/internal/control/lcd_presenter_test.go index 4fdf7faf..16b09354 100644 --- a/Tools/Controller/internal/control/lcd_presenter_test.go +++ b/Tools/Controller/internal/control/lcd_presenter_test.go @@ -9,6 +9,36 @@ import ( "pccontroller.local/controller/internal/native" ) +func TestLCDTimelineDeferralDoesNotClaimErrorOrConfirmedDisplay(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + runtime.mediaTimeline.mu.Lock() + runtime.mediaTimeline.status.State = "playing" + runtime.mediaTimeline.mu.Unlock() + err := runtime.rejectMediaTimelineConflict(context.Background(), native.OpDisplayText) + var busy *ResourceBusyError + if !errors.As(err, &busy) || busy.Resource != "serial_channel" || busy.Owner != "media_timeline" { + t.Fatalf("output conflict lost typed ownership: %v", err) + } + presenter := NewLCDPresenter(runtime) + presenter.options.Enabled = true + presenter.send(0, "waiting", "for playback") + if presenter.firmwareLines != [2]string{} || presenter.firmwareDevice != "" { + t.Fatal("deferred presentation was reported as confirmed") + } + runtime.eventMu.Lock() + defer runtime.eventMu.Unlock() + deferred := false + for _, event := range runtime.eventLog { + if event.Kind == "lcd.error" { + t.Fatalf("intentional reservation reported as error: %s", event.Text) + } + deferred = deferred || event.Kind == "lcd.deferred" + } + if !deferred { + t.Fatal("missing honest deferral event") + } +} + func TestLCDPromptChangedDuringPriorityRestoresNewestPrompt(t *testing.T) { presenter := NewLCDPresenter(nil) if err := presenter.Configure(LCDPresentationOptions{ diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index 6ed4f297..1e801529 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -118,7 +118,8 @@ func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode } status := runtime.MediaTimeline() if status.State == "playing" && hostRecordableOpcode(opcode) { - return errors.New("pause the prepared media timeline before issuing competing live output commands") + return &ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline", + Message: "pause the prepared media timeline before issuing competing live output commands"} } return nil } diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 5d817829..723448a7 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -3331,7 +3331,14 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { } runtime.publishEvent(deviceEvent) } else { - runtime.publish(kind, text, event.Frame) + if event.Frame.Opcode == native.OpACK && !event.ReceivedAt.IsZero() { + runtime.publishEvent(Event{Kind: kind, Text: text, Frame: event.Frame, Source: "board", Target: "host", Metadata: map[string]string{ + "transport_received_at": event.ReceivedAt.Format(time.RFC3339Nano), + "observer_delay_ms": fmt.Sprintf("%.3f", time.Since(event.ReceivedAt).Seconds()*1000), + }}) + } else { + runtime.publish(kind, text, event.Frame) + } } if rfMappingRequired && parsedDevice != nil { runtime.publishRFMappingRequired(*parsedDevice, rfCaptured) diff --git a/Tools/Controller/internal/link/response_timing_test.go b/Tools/Controller/internal/link/response_timing_test.go new file mode 100644 index 00000000..c1191cef --- /dev/null +++ b/Tools/Controller/internal/link/response_timing_test.go @@ -0,0 +1,51 @@ +package link + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "pccontroller.local/controller/internal/native" +) + +func TestLateACKRetainsReceiveTimeAfterRequestCancellation(t *testing.T) { + port := newFakePort() + session := NewForPort("TEST", port) + defer session.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var request native.Frame + port.onWrite = func(encoded []byte) { + var err error + request, err = native.Decode(encoded) + if err != nil { + t.Fatal(err) + } + cancel() + } + _, err := session.Request(ctx, native.OpRelayAllOff, nil, native.OpACK) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } + for _, field := range []string{"seq=", "admission=", "write=", "response=", "read_return_age=", "data_age="} { + if !strings.Contains(err.Error(), field) { + t.Fatalf("missing %s in %v", field, err) + } + } + lateACK, err := native.Encode(native.Frame{Opcode: native.OpACK, Seq: request.Seq, Payload: []byte{native.OpRelayAllOff, 0}}) + if err != nil { + t.Fatal(err) + } + before := time.Now() + port.reads <- lateACK + select { + case event := <-session.Events(): + if event.Frame.Opcode != native.OpACK || event.ReceivedAt.Before(before) || event.ReceivedAt.After(time.Now()) { + t.Fatalf("late ACK lost receive evidence: %#v", event) + } + case <-time.After(time.Second): + t.Fatal("late ACK was swallowed by the canceled waiter") + } +} diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 5910a59f..f3427dd2 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -46,6 +47,8 @@ type serialOpenAcquisition struct { type Event struct { Frame native.Frame Err error + // ReceivedAt is when Read returned these bytes, before decoder/observer work. + ReceivedAt time.Time // Recoverable identifies a decoder anomaly that was isolated at the next // frame delimiter. The transport is still usable and later frames remain // authoritative; consumers must not treat this as a disconnect cause. @@ -69,16 +72,18 @@ type Session struct { closing chan struct{} done chan struct{} - closeMu sync.Mutex - observerMu sync.Mutex - beforeClose func() - closeSeen bool - closingOnce sync.Once - doneOnce sync.Once - closed bool - readDone sync.WaitGroup - pendingOpen *serialOpenAcquisition - rfWire rfWireState + closeMu sync.Mutex + observerMu sync.Mutex + beforeClose func() + closeSeen bool + closingOnce sync.Once + doneOnce sync.Once + closed bool + readDone sync.WaitGroup + pendingOpen *serialOpenAcquisition + rfWire rfWireState + lastReadReturned atomic.Int64 + lastDataReceived atomic.Int64 } type sessionPort interface { @@ -526,6 +531,7 @@ func (s *Session) RequestWithPriority( return native.Frame{}, s.requestSlotError(opcode, waitStarted, err) } defer releaseRequest() + admittedAt := time.Now() if opcode == native.OpAddressableLED { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, stripResponseTimeout) @@ -548,14 +554,16 @@ func (s *Session) RequestWithPriority( if err != nil { return native.Frame{}, err } + writeStarted := time.Now() if err := s.writeRawContext(ctx, encoded); err != nil { return native.Frame{}, err } + writeFinished := time.Now() for { select { case <-ctx.Done(): - return native.Frame{}, fmt.Errorf("waiting for opcode 0x%02X response: %w", opcode, ctx.Err()) + return native.Frame{}, s.responseWaitError(opcode, sequence, waitStarted, admittedAt, writeStarted, writeFinished, ctx.Err()) case <-s.closing: return native.Frame{}, ErrClosed case response := <-waiter.channel: @@ -577,6 +585,22 @@ func (s *Session) RequestWithPriority( } } +// Diagnostic ages measure host receive-pump progress, not MCU execution time. +// Keep sequence and phase timing without including command payloads or paths. +func (s *Session) responseWaitError(opcode, sequence byte, started, admitted, writeStarted, written time.Time, cause error) error { + now := time.Now() + age := func(stamp int64) float64 { + if stamp == 0 { + return -1 + } + return float64(now.UnixNano()-stamp) / float64(time.Millisecond) + } + return fmt.Errorf("waiting for opcode 0x%02X response: %w (seq=%d admission=%.1f ms prepare=%.1f ms write=%.1f ms response=%.1f ms read_return_age=%.1f ms data_age=%.1f ms)", + opcode, cause, sequence, admitted.Sub(started).Seconds()*1000, writeStarted.Sub(admitted).Seconds()*1000, + written.Sub(writeStarted).Seconds()*1000, now.Sub(written).Seconds()*1000, + age(s.lastReadReturned.Load()), age(s.lastDataReceived.Load())) +} + // Capture the registered request occupying the slot at failure, without // exposing command payloads, device names or client information. Admission may // race with completion, so explicitly report when no request is registered. @@ -818,7 +842,10 @@ func (s *Session) readLoop() { for { n, err := s.port.Read(buffer) + receivedAt := time.Now() + s.lastReadReturned.Store(receivedAt.UnixNano()) if n > 0 { + s.lastDataReceived.Store(receivedAt.UnixNano()) frames, decodeErrors := decoder.Feed(buffer[:n]) for _, decodeErr := range decodeErrors { s.publish(Event{Err: decodeErr, Recoverable: true}) @@ -827,7 +854,7 @@ func (s *Session) readLoop() { s.observeRFClock(frame) s.observeHello(frame) if !s.deliver(frame) { - s.publish(Event{Frame: frame}) + s.publish(Event{Frame: frame, ReceivedAt: receivedAt}) } } } diff --git a/Tools/Controller/status_subscription_test.go b/Tools/Controller/status_subscription_test.go index 0a33cb61..b4d13d3f 100644 --- a/Tools/Controller/status_subscription_test.go +++ b/Tools/Controller/status_subscription_test.go @@ -7,9 +7,53 @@ import ( "testing" "time" + "pccontroller.local/controller/internal/control" "pccontroller.local/controller/internal/native" ) +func TestStatusSubscriptionReservationDefersWithoutErrorOrFakeSample(t *testing.T) { + var calls atomic.Int32 + secondFetch := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(release) }) + client := &Client{statusFetch: func(context.Context) (Status, error) { + if calls.Add(1) == 1 { + return Status{}, &control.ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline"} + } + if calls.Load() == 2 { + close(secondFetch) + } + <-release + return Status{SupplyMV: 12345}, nil + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + updates, err := client.SubscribeStatus(ctx, 50*time.Millisecond) + if err != nil { + t.Fatal(err) + } + select { + case <-secondFetch: + case <-time.After(time.Second): + t.Fatal("deferred subscription did not advance its cadence") + } + select { + case update := <-updates: + t.Fatalf("deferral published an error or fake sample: %+v", update) + default: + } + once.Do(func() { close(release) }) + select { + case update := <-updates: + if update.Error != "" || update.Status.SupplyMV != 12345 { + t.Fatalf("bad resumed observation: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("subscription did not resume after deferral") + } +} + func TestStatusSubscriptionHubDoesNotMultiplyPhysicalPolling(t *testing.T) { var fetches atomic.Int32 fetchStarted := make(chan struct{}, 2) diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index abc3bd5b..e8396fdc 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -64,3 +64,28 @@ the current listener. A prior updater reported a new disk digest while an old TUI subsequently took primary ownership. That old process was gracefully closed with operator approval and the ci.1647 listener verified separately. A disk artifact manifest alone does not prove which executable image owns the board. + +The paired ci.1650 / Pealayer c48c2e7 live replay reached the reserved channel, +but failed on a resume relay ACK deadline (47.6 ms). The unmatched ACK appeared +later, with its MCU timestamp only 280 microseconds after the relay edge. +Framing/CRC remained zero and the reset count did not change; cleanup stopped +both seats and cleared relays. This is not synchronized-playback acceptance. +The existing history time is publication time, so it cannot establish whether +the remaining delay occurred in the driver, reader, or event observer. + +Response timeout diagnostics now retain the sequence and admission, preparation, +write and response-wait durations, plus ages of the last Read return and received +data (-1 means no observation). Write timing includes the writer gate. Unmatched +ACK history separately records the Read-return timestamp and delay to observer +publication. These are host observations, not MCU execution durations; no cue or +freshness deadline changes and no retry of an uncertain mutation are introduced. + +A bounded read-only Normal/AboveNormal process-priority comparison did not +improve latency tails; the original priority was restored. A second playback +attempt restored outputs within 30 ms and entered playback, but the test harness +mistook an intentional reservation notification for a fatal error and paused it. +That incomplete run is not a playback pass. The host subscription producer now +recognizes only the typed serial-channel/media-timeline deferral: it advances +its polling cadence without emitting an error or a fabricated fresh observation. +Other failures remain visible. Competing output commands retain the same typed +ownership error, and automatic LCD presentation reports deferral honestly. From 63a2314011f5a49e9fcf5e6679691f9984d2f108 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:04:25 +0330 Subject: [PATCH 19/29] Trace executor scheduling and enforce restore ACK deadline --- .../internal/control/media_timeline.go | 17 ++++++++- .../internal/control/media_timeline_test.go | 13 +++++++ .../internal/ipcjson/cpu_profile.go | 17 +++++++-- .../internal/ipcjson/cpu_profile_test.go | 36 +++++++++++++++---- Tools/Controller/internal/ipcjson/ipc.go | 15 +++++--- docs/STRIP-WORKSPACE-INTEGRATION.md | 11 ++++++ 6 files changed, 94 insertions(+), 15 deletions(-) diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index 1e801529..a844bb4a 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -496,6 +496,19 @@ func mediaTimelineRestore(steps []mediaTimelineStep, index int) []mediaTimelineS } return restored } + +// A timer callback may itself be delayed. A successful response must still +// satisfy the elapsed budget; receiving an ACK does not make a late edge timely. +func mediaTimelineRestoreAckError(err error, elapsed, budget time.Duration) error { + if err != nil { + return err + } + if elapsed > budget { + return fmt.Errorf("ACK exceeded %.1f ms restore budget", budget.Seconds()*1000) + } + return nil +} + func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{}, plan MediaTimelinePlan, steps []mediaTimelineStep, generation uint64) { defer close(done) defer runtime.releaseMediaTimelineWire() @@ -627,7 +640,9 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} requestStarted := time.Now() frame, err := runtime.requestAtGenerationPriority(requestCtx, generation, link.RequestPriorityTimeline, step.opcode, step.payload, native.OpACK) cancel() - ackMS := time.Since(requestStarted).Seconds() * 1000 + elapsed := time.Since(requestStarted) + ackMS := elapsed.Seconds() * 1000 + err = mediaTimelineRestoreAckError(err, elapsed, time.Duration(plan.MaxLatenessMS)*time.Millisecond) runtime.mediaTimeline.mu.Lock() runtime.mediaTimeline.status.LastRestoreStep = step.id runtime.mediaTimeline.status.LastRestoreAckMS = ackMS diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index 62e6ab58..4f003ac1 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -13,6 +13,19 @@ import ( "time" ) +func TestMediaTimelineRestoreChecksElapsedBudgetAfterSuccessfulACK(t *testing.T) { + budget := 50 * time.Millisecond + if err := mediaTimelineRestoreAckError(nil, budget, budget); err != nil { + t.Fatal(err) + } + if err := mediaTimelineRestoreAckError(nil, budget+time.Nanosecond, budget); err == nil { + t.Fatal("late successful ACK bypassed restore deadline") + } + if err := mediaTimelineRestoreAckError(context.DeadlineExceeded, time.Millisecond, budget); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("original request failure lost: %v", err) + } +} + func TestMediaTimelineBackgroundTrafficCannotBlockPreparedEdges(t *testing.T) { runtime, frames, mu := mediaTimelineFixture(t, false) value := armTimeline(t, runtime, 0) diff --git a/Tools/Controller/internal/ipcjson/cpu_profile.go b/Tools/Controller/internal/ipcjson/cpu_profile.go index 07e23126..9a546974 100644 --- a/Tools/Controller/internal/ipcjson/cpu_profile.go +++ b/Tools/Controller/internal/ipcjson/cpu_profile.go @@ -2,9 +2,11 @@ package ipcjson import ( "bytes" + "io" "net" "net/http" "runtime/pprof" + "runtime/trace" "sync/atomic" "time" ) @@ -14,6 +16,15 @@ 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) { + service.serveApplicationProfile(writer, request, pprof.StartCPUProfile, pprof.StopCPUProfile, "controller-cpu.pprof") +} + +func (service *Service) serveExecutionTrace(writer http.ResponseWriter, request *http.Request) { + service.serveApplicationProfile(writer, request, trace.Start, trace.Stop, "controller-execution.trace") +} + +// CPU and execution captures share exclusion and the same five-second bound. +func (service *Service) serveApplicationProfile(writer http.ResponseWriter, request *http.Request, start func(io.Writer) error, stop func(), filename string) { if request.Method != http.MethodPost { writer.Header().Set("Allow", http.MethodPost) writeHTTPJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) @@ -37,7 +48,7 @@ func (service *Service) serveCPUProfile(writer http.ResponseWriter, request *htt } defer cpuProfileBusy.Store(false) var profile bytes.Buffer - if err := pprof.StartCPUProfile(&profile); err != nil { + if err := start(&profile); err != nil { writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": "application profiler unavailable"}) return } @@ -47,9 +58,9 @@ func (service *Service) serveCPUProfile(writer http.ResponseWriter, request *htt case <-timer.C: case <-request.Context().Done(): } - pprof.StopCPUProfile() + stop() 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.Header().Set("Content-Disposition", "attachment; filename="+filename) _, _ = writer.Write(profile.Bytes()) } diff --git a/Tools/Controller/internal/ipcjson/cpu_profile_test.go b/Tools/Controller/internal/ipcjson/cpu_profile_test.go index e8995fe3..59bb4136 100644 --- a/Tools/Controller/internal/ipcjson/cpu_profile_test.go +++ b/Tools/Controller/internal/ipcjson/cpu_profile_test.go @@ -1,11 +1,31 @@ package ipcjson import ( + "context" + "io" "net/http" "net/http/httptest" "testing" ) +func TestApplicationProfileCancellationStopsCaptureAndReleasesOwnership(t *testing.T) { + service, _ := browserUIConfigTestService(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodPost, "/api/diagnostics/execution-trace", nil).WithContext(ctx) + request.RemoteAddr = "127.0.0.1:12000" + started, stopped := false, false + response := httptest.NewRecorder() + service.serveApplicationProfile(response, request, func(w io.Writer) error { + started = true + _, err := w.Write([]byte("trace fixture")) + return err + }, func() { stopped = true }, "controller-execution.trace") + if response.Code != http.StatusOK || !started || !stopped || cpuProfileBusy.Load() { + t.Fatalf("capture ownership leaked: status=%d started=%v stopped=%v busy=%v", response.Code, started, stopped, cpuProfileBusy.Load()) + } +} + func TestCPUProfileRejectsNonlocalAndCrossOriginRequests(t *testing.T) { service, _ := browserUIConfigTestService(t) for _, test := range []struct { @@ -17,13 +37,15 @@ func TestCPUProfileRejectsNonlocalAndCrossOriginRequests(t *testing.T) { {"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) + for _, handler := range []func(http.ResponseWriter, *http.Request){service.serveCPUProfile, service.serveExecutionTrace} { + request := httptest.NewRequest(test.method, "/api/diagnostics/cpu-profile", nil) + request.RemoteAddr = test.address + request.Header.Set("Origin", test.origin) + response := httptest.NewRecorder() + handler(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 8fce314c..92e17bb4 100644 --- a/Tools/Controller/internal/ipcjson/ipc.go +++ b/Tools/Controller/internal/ipcjson/ipc.go @@ -1255,10 +1255,16 @@ func (service *Service) dispatch( case "controller.rf.catalog": result, err = service.rfCatalog(ctx, request.Params) case "controller.rf.observed.add": - var params struct { Code uint32 `json:"code"`; Bits byte `json:"bits"`; Protocol byte `json:"protocol"` } - if err = decodeStrictParams(request.Params,¶ms); err == nil { - err = service.Client.AddObservedRF(ctx,params.Code,params.Bits,params.Protocol) - if err == nil { result,err = service.Client.ListLearnedDetailed(ctx) } + var params struct { + Code uint32 `json:"code"` + Bits byte `json:"bits"` + Protocol byte `json:"protocol"` + } + if err = decodeStrictParams(request.Params, ¶ms); err == nil { + err = service.Client.AddObservedRF(ctx, params.Code, params.Bits, params.Protocol) + if err == nil { + result, err = service.Client.ListLearnedDetailed(ctx) + } } case "controller.rf.binding.put": result, err = service.putRFBinding(request.Params) @@ -3118,6 +3124,7 @@ func websocketMux(serverContext context.Context, service *Service) http.Handler socketIOPath := service.currentSocketIOPath() mux := http.NewServeMux() mux.HandleFunc("/api/diagnostics/cpu-profile", service.serveCPUProfile) + mux.HandleFunc("/api/diagnostics/execution-trace", service.serveExecutionTrace) mux.HandleFunc(webSocketPath, func(writer http.ResponseWriter, request *http.Request) { if !authorizeHTTPRequest(writer, request, service) { return diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index e8396fdc..0f10aaaf 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -89,3 +89,14 @@ recognizes only the typed serial-channel/media-timeline deferral: it advances its polling cadence without emitting an error or a fabricated fresh observation. Other failures remain visible. Competing output commands retain the same typed ownership error, and automatic LCD presentation reports deferral honestly. + +The ci.1651 replay emitted no false background errors, but missed the first +strip configure dispatch by 54.1 ms after a 61.1 ms executor iteration gap. It +refused the expired command, then paused with outputs stopped and unchanged +serial/reset counters. A paused CPU profile did not establish the cause. +`POST /api/diagnostics/execution-trace` now offers the same local-only, +authenticated, body-free five-second capture contract as the CPU profiler; +captures share exclusion and remain private diagnostic data. It is intended to +separate runnable delay, blocking and GC in one coordinated replay. Restore ACKs +also receive a post-response elapsed-budget check, because delayed timer +callbacks must not admit an acknowledgement that exceeded its original budget. From 4c17b973752c75a35839e3c2c82d7b2fc8026d25 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:07:28 +0330 Subject: [PATCH 20/29] Document bounded execution trace in API catalog --- Tools/Audit/generate-api-reference.mjs | 1 + Tools/Controller/api/openapi.json | 47 ++++++++++++++++++++++++++ Tools/Controller/api/reference.html | 6 ++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Tools/Audit/generate-api-reference.mjs b/Tools/Audit/generate-api-reference.mjs index 16b68f2e..99f08450 100644 --- a/Tools/Audit/generate-api-reference.mjs +++ b/Tools/Audit/generate-api-reference.mjs @@ -293,6 +293,7 @@ const routes = [ { 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/diagnostics/execution-trace", methods: ["post"], capability: "local-only", binaryResponse: true, summary: "Explicit five-second Go execution trace; loopback callers only, shared profile capture exclusion, 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" }, diff --git a/Tools/Controller/api/openapi.json b/Tools/Controller/api/openapi.json index 437e2201..c99cd03d 100644 --- a/Tools/Controller/api/openapi.json +++ b/Tools/Controller/api/openapi.json @@ -334,6 +334,53 @@ } } }, + "/api/diagnostics/execution-trace": { + "post": { + "operationId": "post_api_diagnostics_execution_trace", + "summary": "Explicit five-second Go execution trace; loopback callers only, shared profile capture exclusion, no board actions or request body", + "tags": [ + "execution-trace" + ], + "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", diff --git a/Tools/Controller/api/reference.html b/Tools/Controller/api/reference.html index 02e2d4a4..aa1c8fb7 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 schema189 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
+
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 schema189 RPC methods89 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
POST/api/diagnostics/execution-traceExplicit five-second Go execution trace; loopback callers only, shared profile capture exclusion, 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 a partial PWM channel configuration (output type, icon, curve, gamma, step_percent, on_behavior full|restore), preserving omitted fields, 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.trace.getReturn bounded DEBUG-only PWM acknowledgements; no board read or output change.readsafe
controller.pwm.trace.startTemporarily capture one channel 0..15 for explicit duration_ms 1..60000, off by default; no board operation.eventssafe
controller.pwm.trace.stopStop temporary PWM acknowledgement capture and retain bounded receipts; no board operation.eventssafe
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.putCreate a uniquely named validated RF binding, or explicitly edit with previous_name; reject name/stale-target conflicts without replacing unrelated assignments.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.observed.addSave a waveform actually received from the current board, unassigned, with preserved records and verified readback.board_commandsidempotent-with-authoritative-readback
controller.rf.preferences.setSave timed-learning, duration and continuous-capture preferences without entering learning.host_configurationidempotent-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 37d7181d0d839dbe2aba78f731adc11b93378796c26478bbff0c5bd7cc6bf5fa. Generated by Tools/Audit/generate-api-reference.mjs.

+

Contract digest 993a4c468b4553a208793aa8744ea56ce46d8a3854d3bfd0f541b1d210c58785. Generated by Tools/Audit/generate-api-reference.mjs.

From e3710fc9a53db3b6fffafa6ca6b3acbbfdbd8867 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:30:38 +0330 Subject: [PATCH 21/29] Avoid effect catalog expansion in live board observers --- Tools/Controller/controller.go | 27 +++++++++++++---- Tools/Controller/controller_test.go | 8 ++++- .../internal/control/media_timeline_test.go | 30 +++++++++++++++++++ .../internal/control/output_streams.go | 18 ++++++----- Tools/Controller/internal/control/runtime.go | 10 +++++-- .../Controller/internal/hostbridge/manager.go | 2 +- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 11 +++++++ 7 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 docs/STRIP-TIMING-TRACE-CHECKPOINT.md diff --git a/Tools/Controller/controller.go b/Tools/Controller/controller.go index 1eed2bf2..bc2eb3a4 100644 --- a/Tools/Controller/controller.go +++ b/Tools/Controller/controller.go @@ -2337,12 +2337,26 @@ func (client *Client) MapLearnedRF( // Snapshot returns cached board state without polling firmware. When no // transport candidate is cached, serial-reset capability discovery may perform // one throttled host-side device enumeration. -func (client *Client) Snapshot() Snapshot { - snapshot := client.runtime.Snapshot() - resetLinesAvailable, resetLinesPort, resetLinesReason := client.runtime.ResetLinesCapability() +func (client *Client) Snapshot() Snapshot { return client.snapshot(true) } + +// BoardSnapshot is for internal live-state observers. It omits catalogs, macro +// libraries and reset-device enumeration; public query snapshots remain complete. +func (client *Client) BoardSnapshot() Snapshot { return client.snapshot(false) } + +func (client *Client) snapshot(includeCatalog bool) Snapshot { + var snapshot control.Snapshot + var resetLinesAvailable bool + var resetLinesPort ports.Info + var resetLinesReason string + if includeCatalog { + snapshot = client.runtime.Snapshot() + resetLinesAvailable, resetLinesPort, resetLinesReason = client.runtime.ResetLinesCapability() + } else { + snapshot = client.runtime.BoardSnapshot() + } // Library snapshots belong to client queries, not the hot board-status // path: copying a long take for every internal status check is unnecessary. - if runner := client.runtime.MacroRunner(); runner != nil { + if runner := client.runtime.MacroRunner(); includeCatalog && runner != nil { snapshot.Macros = runner.Snapshot() } client.illuminationMu.RLock() @@ -2351,7 +2365,10 @@ func (client *Client) Snapshot() Snapshot { if !snapshot.Connected || !snapshot.HaveSettings || !snapshot.HaveStatus { illumination = IlluminationState{} } - stripEffects := control.SupportedConfiguredStripEffectDescriptors(snapshot.Connected, snapshot.Hello.Capabilities, client.currentStripEffects()) + var stripEffects []control.StripEffectDescriptor + if includeCatalog { + stripEffects = control.SupportedConfiguredStripEffectDescriptors(snapshot.Connected, snapshot.Hello.Capabilities, client.currentStripEffects()) + } return Snapshot{ Connected: snapshot.Connected, Paused: snapshot.Paused, diff --git a/Tools/Controller/controller_test.go b/Tools/Controller/controller_test.go index 00754016..a24b8474 100644 --- a/Tools/Controller/controller_test.go +++ b/Tools/Controller/controller_test.go @@ -27,12 +27,18 @@ func TestPublicPortInfoPublishesCrossPlatformDisplayIdentity(t *testing.T) { func TestSharedFacadeSnapshotUsesRuntimeEffectCatalog(t *testing.T) { runtime := control.New(control.Options{}) config := appconfig.Defaults() + configReads := 0 engine := control.NewCommandEngine(runtime, control.CommandOptions{ - HostConfig: func() appconfig.Config { return config }, + HostConfig: func() appconfig.Config { configReads++; return config }, }) client := AttachSharedRuntime(runtime, engine) defer client.Shutdown() + configReads = 0 + board := client.BoardSnapshot() + if configReads != 0 || len(board.Effects) != 0 || len(board.StripEffects) != 0 { + t.Fatal("internal board observation expanded configuration or catalogs") + } snapshot := client.Snapshot() if len(snapshot.Effects) != len(config.StripEffects) { t.Fatalf("snapshot effects=%d, want runtime catalog=%d", len(snapshot.Effects), len(config.StripEffects)) diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index 4f003ac1..7c7cee14 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -583,3 +583,33 @@ func TestMediaTimelinePauseResumeRestoresLatchedValueAndSeekRequiresArming(t *te } waitTimeline(t, runtime, func(s MediaTimelineStatus) bool { return s.ArmedEpoch == 2 && s.RebasedSteps == 2 }) } + +func TestBoardSnapshotSkipsCatalogProvidersAndRetainsLiveState(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + calls := 0 + runtime.setMacroRunner(NewMacroRunner(nil, func() []appconfig.Macro { + calls++ + return []appconfig.Macro{{ID: 7, Name: "fixture", Mode: macroModeHost, + Steps: []appconfig.MacroStep{{Kind: "relay", Target: 7, Value: 0}}}} + }, nil)) + runtime.mu.Lock() + runtime.haveStatus = true + runtime.status.DoorOpen = true + runtime.statusUpdated = time.Now() + runtime.mu.Unlock() + light := runtime.BoardSnapshot() + if calls != 0 { + t.Fatalf("board observation expanded catalog %d times", calls) + } + full := runtime.Snapshot() + if calls == 0 || len(full.Effects) == 0 { + t.Fatal("public snapshot lost its catalog") + } + if light.Status != full.Status || light.StatusUpdated != full.StatusUpdated || !light.HaveStatus { + t.Fatal("board observation lost authoritative state or timestamp") + } + if len(light.Effects) != 0 || len(light.EffectGroups) != 0 { + t.Fatal("board observation includes catalog") + } +} diff --git a/Tools/Controller/internal/control/output_streams.go b/Tools/Controller/internal/control/output_streams.go index 4374c469..a0361258 100644 --- a/Tools/Controller/internal/control/output_streams.go +++ b/Tools/Controller/internal/control/output_streams.go @@ -216,7 +216,14 @@ func retainedStatusOwner(owner *retainedOutput) string { } func (scheduler *OutputScheduler) targetSnapshot() Snapshot { - reporter, ok := scheduler.target.(outputCapabilityReporter) + return outputBoardSnapshot(scheduler.target) +} + +func outputBoardSnapshot(target outputCommander) Snapshot { + if reporter, ok := target.(interface{ BoardSnapshot() Snapshot }); ok { + return reporter.BoardSnapshot() + } + reporter, ok := target.(outputCapabilityReporter) if !ok { return Snapshot{} } @@ -823,16 +830,11 @@ func (scheduler *OutputScheduler) sendStatusDescriptor( } func supportsNativeStatusEffects(target outputCommander) bool { - reporter, ok := target.(outputCapabilityReporter) - return ok && reporter.Snapshot().Hello.Capabilities&native.CapabilityStatusEffects != 0 + return outputBoardSnapshot(target).Hello.Capabilities&native.CapabilityStatusEffects != 0 } func supportsNativeStatusProfiles(target outputCommander) bool { - reporter, ok := target.(outputCapabilityReporter) - if !ok { - return false - } - capabilities := reporter.Snapshot().Hello.Capabilities + capabilities := outputBoardSnapshot(target).Hello.Capabilities return capabilities&native.CapabilityStatusEffects != 0 && capabilities&native.CapabilityStatusProfiles != 0 } diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 723448a7..312a71d3 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -815,7 +815,13 @@ func eventKindMatches(requested, actual string) bool { (requested != "" && strings.HasPrefix(actual, requested+".")) } -func (runtime *Runtime) Snapshot() Snapshot { +func (runtime *Runtime) Snapshot() Snapshot { return runtime.snapshot(true) } + +// BoardSnapshot returns cached live state without expanding effect catalogs. +// Internal observers must not copy the user library for every telemetry event. +func (runtime *Runtime) BoardSnapshot() Snapshot { return runtime.snapshot(false) } + +func (runtime *Runtime) snapshot(includeCatalog bool) Snapshot { programState := runtime.ProgramState() rfLearning := runtime.RFLearnState() emergencyStop := runtime.EmergencyStop() @@ -870,7 +876,7 @@ func (runtime *Runtime) Snapshot() Snapshot { // user effects. Never hold the connection lock across that presentation work: // a waiting writer also blocks subsequent RWMutex readers, including the // prepared executor's small session-generation check. - if runner != nil { + if includeCatalog && runner != nil { snapshot.Effects = runner.EffectCatalog() snapshot.EffectGroups = runner.EffectGroups() } diff --git a/Tools/Controller/internal/hostbridge/manager.go b/Tools/Controller/internal/hostbridge/manager.go index ed93e094..430f9be8 100644 --- a/Tools/Controller/internal/hostbridge/manager.go +++ b/Tools/Controller/internal/hostbridge/manager.go @@ -977,7 +977,7 @@ func (manager *Manager) eventLoop(afterID uint64) { ) } config := manager.store.Current() - manager.observeEventPresentation(config, event, manager.client.Snapshot) + manager.observeEventPresentation(config, event, manager.client.BoardSnapshot) manager.dispatchWebhooks(config, event) manager.dispatchTextMappings(config, event) manager.dispatchNotification(config, event) diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md new file mode 100644 index 00000000..51231627 --- /dev/null +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -0,0 +1,11 @@ +# Strip integration timing checkpoint + +The synchronized restore failure on host ci.1653 was distinct from serial framing corruption. A five-second execution trace showed the timeline worker starting its request at +173.45 ms, the serial reader delivering the ACK at +189.335 ms, and the worker resuming at +234.281 ms. Delivery took about 15.9 ms; the runnable worker then waited 44.95 ms. A GC sweep-termination stop-the-world interval occupied 15.59 ms of that wait. These are host observations, not hardware arrival timestamps. + +The allocation triggering that collection came from hostbridge event presentation -> Client.Snapshot -> effect groups -> Store.Current -> deep configuration clone. Door, segment and status-light observers do not need the effect library. BoardSnapshot now supplies their cached live state without catalog providers, macro library copies or reset-device enumeration. The facade regression also caught an indirect catalog expansion through output-status capability checks; those checks now prefer the same board-only observation while preserving the existing fallback for other commanders. Full public query snapshots still contain the catalogs. Regression coverage checks provider exclusion, retained state/timestamps, public catalog preservation and shared fresh presentation observations. + +This removes demonstrated unnecessary allocation; it does not prove every prior timing failure had the same cause or guarantee a Windows scheduling deadline. The 50 ms limit remains unchanged. The next gate is hosted CI, exact-artifact host update, and a coordinated unprofiled synchronized replay with cleanup and counter checks. Do not count the profiled failure as playback acceptance. + +Standalone evidence remains separate: a three-pixel 180-second stream and 150 seat Up requests over 15 seconds had no new framing/CRC/reset errors. Identical seat commands coalesce, so this does not establish 150 relay cycles. Police, White Thunder, Converging, Fade and Pulse demos are available; visual, physical and audible acceptance remains with the user. Android installation and native modal visual verification are still open. + +Private traces, machine configuration and captures are retained locally, not published. From 16cd25a1cc7945fbc775243d23b317c445f5398d Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:50:19 +0330 Subject: [PATCH 22/29] Yield delivered serial replies before blocking reads and reduce event allocations --- Tools/Controller/internal/control/history.go | 24 ++++++++--------- .../internal/control/history_test.go | 26 +++++++++++++++++++ Tools/Controller/internal/control/macros.go | 8 ++++-- .../Controller/internal/hostbridge/manager.go | 2 +- Tools/Controller/internal/link/session.go | 14 +++++++++- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 8 ++++++ 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/Tools/Controller/internal/control/history.go b/Tools/Controller/internal/control/history.go index f927080a..9cbaa666 100644 --- a/Tools/Controller/internal/control/history.go +++ b/Tools/Controller/internal/control/history.go @@ -320,10 +320,7 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { statusStart++ } if statusStart != 0 { - runtime.statusHistory = append( - []StatusSample(nil), - runtime.statusHistory[statusStart:]..., - ) + runtime.statusHistory = retainHistorySuffix(runtime.statusHistory, statusStart) } timelineStart := 0 for timelineStart < len(runtime.timeline) && @@ -331,20 +328,23 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { timelineStart++ } if timelineStart != 0 { - runtime.timeline = append( - []TimelineEntry(nil), - runtime.timeline[timelineStart:]..., - ) + runtime.timeline = retainHistorySuffix(runtime.timeline, timelineStart) } } if runtime.timelineLimit > 0 && len(runtime.timeline) > runtime.timelineLimit { - runtime.timeline = append( - []TimelineEntry(nil), - runtime.timeline[len(runtime.timeline)-runtime.timelineLimit:]..., - ) + runtime.timeline = retainHistorySuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit) } } +// Call only under historyMu. Public readers already receive detached copies. +// Reuse bounded storage and clear discarded references instead of allocating +// the entire retained history for each new event at the retention limit. +func retainHistorySuffix[T any](entries []T, start int) []T { + retained := copy(entries, entries[start:]) + clear(entries[retained:]) + return entries[:retained] +} + func (runtime *Runtime) deduplicateStatusHistoryLocked() { if len(runtime.statusHistory) < 2 { return diff --git a/Tools/Controller/internal/control/history_test.go b/Tools/Controller/internal/control/history_test.go index 6981fd63..9826e951 100644 --- a/Tools/Controller/internal/control/history_test.go +++ b/Tools/Controller/internal/control/history_test.go @@ -212,3 +212,29 @@ func equalStatusSamples(left, right []StatusSample) bool { } return true } + +func TestHistoryPruningReusesStorageAndClearsDiscardedReferences(t *testing.T) { + now := time.Now() + backing := make([]TimelineEntry, 4) + runtime := &Runtime{historyRetention: time.Hour, timelineLimit: 2} + allocations := testing.AllocsPerRun(100, func() { + backing[0] = TimelineEntry{Time: now.Add(-2 * time.Hour), Kind: "expired"} + backing[1] = TimelineEntry{Time: now, Kind: "oldest"} + backing[2] = TimelineEntry{Time: now, Kind: "kept-first"} + backing[3] = TimelineEntry{Time: now, Kind: "kept-last"} + runtime.timeline = backing + runtime.pruneHistoryLocked(now) + }) + if allocations != 0 { + t.Fatalf("prune allocated %.1f times per event", allocations) + } + if len(runtime.timeline) != 2 || runtime.timeline[0].Kind != "kept-first" || runtime.timeline[1].Kind != "kept-last" { + t.Fatalf("wrong retained ordering: %+v", runtime.timeline) + } + if backing[2].Kind != "" || backing[3].Kind != "" { + t.Fatal("discarded references retained") + } + if &runtime.timeline[0] != &backing[0] { + t.Fatal("prune replaced backing storage") + } +} diff --git a/Tools/Controller/internal/control/macros.go b/Tools/Controller/internal/control/macros.go index ef603059..7da37291 100644 --- a/Tools/Controller/internal/control/macros.go +++ b/Tools/Controller/internal/control/macros.go @@ -248,17 +248,21 @@ func (runner *MacroRunner) Snapshot() MacroSnapshot { // interface. Recorded sequences and rendered strip streams retain their // distinct engines, but share discovery, stable references, and commands. func (runner *MacroRunner) EffectCatalog() []EffectDescriptor { - config := appconfig.Defaults() + var config appconfig.Config if runner.hostConfig != nil { config = runner.hostConfig() + } else { + config = appconfig.Defaults() } return EffectCatalogWithGroups(runner.List(), config.StripEffects, config.EffectGroups) } func (runner *MacroRunner) EffectGroups() []EffectGroupDescriptor { - config := appconfig.Defaults() + var config appconfig.Config if runner.hostConfig != nil { config = runner.hostConfig() + } else { + config = appconfig.Defaults() } config.Macros = runner.List() return EffectGroupCatalog(config) diff --git a/Tools/Controller/internal/hostbridge/manager.go b/Tools/Controller/internal/hostbridge/manager.go index 430f9be8..37e43ed5 100644 --- a/Tools/Controller/internal/hostbridge/manager.go +++ b/Tools/Controller/internal/hostbridge/manager.go @@ -1125,7 +1125,7 @@ func (manager *Manager) dispatchNotification( } return } - job, ok, err := notificationJobFromObservation(config, event, manager.client.Snapshot) + job, ok, err := notificationJobFromObservation(config, event, manager.client.BoardSnapshot) if err != nil { manager.recordError("notification actions: " + err.Error()) return diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index f3427dd2..d9c8e6e3 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/url" + "runtime" "strings" "sync" "sync/atomic" @@ -842,6 +843,7 @@ func (s *Session) readLoop() { for { n, err := s.port.Read(buffer) + replyDelivered := false receivedAt := time.Now() s.lastReadReturned.Store(receivedAt.UnixNano()) if n > 0 { @@ -853,7 +855,9 @@ func (s *Session) readLoop() { for _, frame := range frames { s.observeRFClock(frame) s.observeHello(frame) - if !s.deliver(frame) { + if s.deliver(frame) { + replyDelivered = true + } else { s.publish(Event{Frame: frame, ReceivedAt: receivedAt}) } } @@ -881,6 +885,14 @@ func (s *Session) readLoop() { return default: } + if replyDelivered && runtime.GOOS == "windows" { + // Let the awakened request consume its response before this reader + // enters another blocking Win32 serial syscall. Execution traces + // showed the waiter remaining runnable for tens of milliseconds + // while the reader blocked, despite another idle P. + // Yield once per decoded batch, without sleeps or changed deadlines. + runtime.Gosched() + } } } diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index 51231627..200909ae 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -9,3 +9,11 @@ This removes demonstrated unnecessary allocation; it does not prove every prior Standalone evidence remains separate: a three-pixel 180-second stream and 150 seat Up requests over 15 seconds had no new framing/CRC/reset errors. Identical seat commands coalesce, so this does not establish 150 relay cycles. Police, White Thunder, Converging, Fade and Pulse demos are available; visual, physical and audible acceptance remains with the user. Android installation and native modal visual verification are still open. Private traces, machine configuration and captures are retained locally, not published. + +## Post-update result + +Host ci.1654 passed hosted builds and was installed with independently verified live identity. Its unprofiled short replay still failed restore at 63.5 ms before observed playback. One post-fix trace failed at 79.2 ms: the worker waited runnable about 23.2 ms after a write syscall and 43.7 ms after ACK delivery. The relevant stop-the-world intervals were only about 0.50 ms and 0.07 ms, so those pauses alone do not explain the delays. + +The new trace exposed further avoidable allocation: retained timeline trimming allocated a new whole-history slice, and door-warning notification context still expanded the full catalog. The follow-up reuses history storage under its existing lock and clears discarded references; public readers remain detached. Notification context now uses BoardSnapshot. Catalog construction also avoids building defaults only to overwrite them with configured values. Regression coverage checks zero allocations during pruning, retained ordering and cleared references, plus existing history and notification behavior. These reductions remain performance fixes, not proof of synchronized acceptance. No timer, priority or GC-disable workaround is applied. + +The trace also shows ACK delivery at +225.376 ms followed immediately by the reader entering another Win32 blocking wait at +225.394 ms, while a different Go processor remained idle until +253.318 ms. The waiter ran at +269.071 ms. This supports a local run-queue handoff problem rather than claiming GC explains the whole delay. The Windows reader now cooperatively yields once after a decoded batch delivers a pending response, before its next blocking read. It does not sleep, modify process priorities, disable GC or change deadlines. Live comparison remains required. From 4dbdd1798cbb78e6e745996cf2afa6f0dad51337 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:09:20 +0330 Subject: [PATCH 23/29] Reuse bounded event log storage on timeline publication --- .../internal/control/event_log_reuse_test.go | 32 +++++++++++++++++++ Tools/Controller/internal/control/history.go | 10 +++--- Tools/Controller/internal/control/runtime.go | 6 ++-- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 6 ++++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 Tools/Controller/internal/control/event_log_reuse_test.go diff --git a/Tools/Controller/internal/control/event_log_reuse_test.go b/Tools/Controller/internal/control/event_log_reuse_test.go new file mode 100644 index 00000000..6de6ffb3 --- /dev/null +++ b/Tools/Controller/internal/control/event_log_reuse_test.go @@ -0,0 +1,32 @@ +package control + +import "testing" + +func TestEventLogsRetainOrderWithoutReplacingBoundedStorage(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + runtime.eventLog = make([]Event, 512, 513) + runtime.activityLog = make([]Event, 512, 513) + runtime.rfActivityLog = make([]Event, 20, 21) + for i := range runtime.eventLog { + runtime.eventLog[i].ID = uint64(i + 1) + runtime.activityLog[i].ID = uint64(i + 1) + } + for i := range runtime.rfActivityLog { + runtime.rfActivityLog[i].ID = uint64(i + 1) + } + eventBase, activityBase, rfBase := &runtime.eventLog[0], &runtime.activityLog[0], &runtime.rfActivityLog[0] + runtime.nextEventID = 512 + event := runtime.publishEvent(Event{Kind: "rf.fixture", Stream: EventStreamActivity}) + if &runtime.eventLog[0] != eventBase || &runtime.activityLog[0] != activityBase || &runtime.rfActivityLog[0] != rfBase { + t.Fatal("bounded event publication replaced backing storage") + } + for _, entries := range [][]Event{runtime.eventLog, runtime.activityLog, runtime.rfActivityLog} { + if entries[0].ID != 2 || entries[len(entries)-1].ID != event.ID { + t.Fatal("trim changed event order") + } + if entries[:cap(entries)][len(entries)].ID != 0 { + t.Fatal("trim retained discarded tail references") + } + } +} diff --git a/Tools/Controller/internal/control/history.go b/Tools/Controller/internal/control/history.go index 9cbaa666..23839d87 100644 --- a/Tools/Controller/internal/control/history.go +++ b/Tools/Controller/internal/control/history.go @@ -320,7 +320,7 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { statusStart++ } if statusStart != 0 { - runtime.statusHistory = retainHistorySuffix(runtime.statusHistory, statusStart) + runtime.statusHistory = retainSliceSuffix(runtime.statusHistory, statusStart) } timelineStart := 0 for timelineStart < len(runtime.timeline) && @@ -328,18 +328,18 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { timelineStart++ } if timelineStart != 0 { - runtime.timeline = retainHistorySuffix(runtime.timeline, timelineStart) + runtime.timeline = retainSliceSuffix(runtime.timeline, timelineStart) } } if runtime.timelineLimit > 0 && len(runtime.timeline) > runtime.timelineLimit { - runtime.timeline = retainHistorySuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit) + runtime.timeline = retainSliceSuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit) } } -// Call only under historyMu. Public readers already receive detached copies. +// Call only with exclusive ownership of the slice. Public readers receive detached copies. // Reuse bounded storage and clear discarded references instead of allocating // the entire retained history for each new event at the retention limit. -func retainHistorySuffix[T any](entries []T, start int) []T { +func retainSliceSuffix[T any](entries []T, start int) []T { retained := copy(entries, entries[start:]) clear(entries[retained:]) return entries[:retained] diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 312a71d3..cd3434f4 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -4291,12 +4291,12 @@ func (runtime *Runtime) publishEvent(event Event) Event { runtime.recordRFReceptionLocked(&event) runtime.eventLog = append(runtime.eventLog, event) if len(runtime.eventLog) > 512 { - runtime.eventLog = append([]Event(nil), runtime.eventLog[len(runtime.eventLog)-512:]...) + runtime.eventLog = retainSliceSuffix(runtime.eventLog, len(runtime.eventLog)-512) } if event.Stream == EventStreamActivity { runtime.activityLog = append(runtime.activityLog, event) if len(runtime.activityLog) > 512 { - runtime.activityLog = append([]Event(nil), runtime.activityLog[len(runtime.activityLog)-512:]...) + runtime.activityLog = retainSliceSuffix(runtime.activityLog, len(runtime.activityLog)-512) } } // Keep the RF manager's bounded history independently of high-rate media @@ -4304,7 +4304,7 @@ func (runtime *Runtime) publishEvent(event Event) Event { if strings.HasPrefix(event.Kind, "rf.") && event.Source != "bridge" { runtime.rfActivityLog = append(runtime.rfActivityLog, event) if len(runtime.rfActivityLog) > 20 { - runtime.rfActivityLog = append([]Event(nil), runtime.rfActivityLog[len(runtime.rfActivityLog)-20:]...) + runtime.rfActivityLog = retainSliceSuffix(runtime.rfActivityLog, len(runtime.rfActivityLog)-20) } } close(runtime.eventNotify) diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index 200909ae..a2a3c61a 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -17,3 +17,9 @@ Host ci.1654 passed hosted builds and was installed with independently verified The new trace exposed further avoidable allocation: retained timeline trimming allocated a new whole-history slice, and door-warning notification context still expanded the full catalog. The follow-up reuses history storage under its existing lock and clears discarded references; public readers remain detached. Notification context now uses BoardSnapshot. Catalog construction also avoids building defaults only to overwrite them with configured values. Regression coverage checks zero allocations during pruning, retained ordering and cleared references, plus existing history and notification behavior. These reductions remain performance fixes, not proof of synchronized acceptance. No timer, priority or GC-disable workaround is applied. The trace also shows ACK delivery at +225.376 ms followed immediately by the reader entering another Win32 blocking wait at +225.394 ms, while a different Go processor remained idle until +253.318 ms. The waiter ran at +269.071 ms. This supports a local run-queue handoff problem rather than claiming GC explains the whole delay. The Windows reader now cooperatively yields once after a decoded batch delivers a pending response, before its next blocking read. It does not sleep, modify process priorities, disable GC or change deadlines. Live comparison remains required. + +## Reply-handoff candidate result + +ci.1655 was built, installed and live-identity verified. An unprofiled restore still timed out at 99.8 ms; its unmatched ACK reached the read loop after the fault, with zero observer delay. Nearby board timestamps suggest generation preceded host receipt by roughly 97 ms; this is a clock-alignment inference, not a hardware capture. A subsequent traced short replay did restore and enter Playing. Its configure and frame-stage acknowledgements resumed the waiter within microseconds after delivery, confirming the new handoff path operates. However the stage took 38.2 ms end-to-end, and publishing evidence before show consumed the remaining deadline; show was refused at 50.9 ms late. + +That trace exposed roughly 400 KB event-log allocation at publication: the independent 512-event bounded log still reallocated its retained slice after every append. The follow-up reuses and clears bounded event, activity and RF-log storage under eventMu, preserving order and retention. Regression coverage checks backing storage reuse, newest/oldest order and cleared discarded references. This does not resolve the separate variable read-delivery delay by itself; synchronized acceptance remains open. Native exact-zero seek settlement is tracked separately in Pealayer issue 219. From 2857bf2d1afc4489fd3e1657dacf301d22a03c7a Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:24:49 +0330 Subject: [PATCH 24/29] Bound Windows idle serial reads to ten milliseconds --- .../internal/link/response_timing_test.go | 31 +++++++++++++++++++ Tools/Controller/internal/link/session.go | 8 ++++- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 6 ++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Tools/Controller/internal/link/response_timing_test.go b/Tools/Controller/internal/link/response_timing_test.go index c1191cef..44592186 100644 --- a/Tools/Controller/internal/link/response_timing_test.go +++ b/Tools/Controller/internal/link/response_timing_test.go @@ -49,3 +49,34 @@ func TestLateACKRetainsReceiveTimeAfterRequestCancellation(t *testing.T) { t.Fatal("late ACK was swallowed by the canceled waiter") } } + +func TestFragmentedACKSurvivesEmptyReadIntervals(t *testing.T) { + port := newFakePort() + session := NewForPort("TEST", port) + defer session.Close() + port.onWrite = func(encoded []byte) { + request, err := native.Decode(encoded) + if err != nil { + t.Fatal(err) + } + reply, err := native.Encode(native.Frame{Opcode: native.OpACK, Seq: request.Seq, Payload: []byte{request.Opcode, 0}}) + if err != nil { + t.Fatal(err) + } + for _, value := range reply { + port.reads <- []byte{value} + port.reads <- nil + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + frame, err := session.Request(ctx, native.OpRelayAllOff, nil, native.OpACK) + if err != nil || frame.Opcode != native.OpACK { + t.Fatalf("partial ACK lost across idle reads: %+v, %v", frame, err) + } + select { + case event := <-session.Events(): + t.Fatalf("partial/idle read produced an error or unsolicited frame: %+v", event) + default: + } +} diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index d9c8e6e3..de8a93ab 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -303,7 +303,13 @@ func OpenContext(ctx context.Context, name string, baudRate int) (*Session, erro if err := ctx.Err(); err != nil { return newSession(name, port), fmt.Errorf("open %s canceled after serial acquisition: %w", name, err) } - if err := port.SetReadTimeout(DefaultReadTimeout); err != nil { + readTimeout := DefaultReadTimeout + if runtime.GOOS == "windows" { + // Bound idle overlapped-read residency on Windows/CH340. Empty reads + // retain decoder state; this is not a response or cue deadline. + readTimeout = 10 * time.Millisecond + } + if err := port.SetReadTimeout(readTimeout); err != nil { configureErr := fmt.Errorf("configure %s: %w", name, err) if closeErr := port.Close(); closeErr != nil { // Return a close-only Session together with the error so the caller diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index a2a3c61a..8931781e 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -23,3 +23,9 @@ The trace also shows ACK delivery at +225.376 ms followed immediately by the rea ci.1655 was built, installed and live-identity verified. An unprofiled restore still timed out at 99.8 ms; its unmatched ACK reached the read loop after the fault, with zero observer delay. Nearby board timestamps suggest generation preceded host receipt by roughly 97 ms; this is a clock-alignment inference, not a hardware capture. A subsequent traced short replay did restore and enter Playing. Its configure and frame-stage acknowledgements resumed the waiter within microseconds after delivery, confirming the new handoff path operates. However the stage took 38.2 ms end-to-end, and publishing evidence before show consumed the remaining deadline; show was refused at 50.9 ms late. That trace exposed roughly 400 KB event-log allocation at publication: the independent 512-event bounded log still reallocated its retained slice after every append. The follow-up reuses and clears bounded event, activity and RF-log storage under eventMu, preserving order and retention. Regression coverage checks backing storage reuse, newest/oldest order and cleared discarded references. This does not resolve the separate variable read-delivery delay by itself; synchronized acceptance remains open. Native exact-zero seek settlement is tracked separately in Pealayer issue 219. + +## Shorter Windows read interval experiment + +ci.1656 was verified and installed. Its short unprofiled replay restored and entered Playing, but show expired during a 29.4 ms write after earlier frame operations consumed the shared cue budget. Cleanup retained Stop/Stop, relays off, framing/CRC zero and the same reset count. No full run was claimed. + +The next diagnostic changes only the Windows idle serial read interval from 100 ms to 10 ms, keeping the persistent frame decoder and the original response/cue deadlines. This tests whether shorter blocking-read residency reduces driver/scheduler latency; it is a hypothesis, not a confirmed driver diagnosis. A fragmented ACK regression inserts empty reads between bytes to ensure timeout polling cannot discard frame state. No priority, GC or physical-output policy changes accompany it. From 6233453c3fe4b04e2e619166f84b9d33768c57f5 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:47:37 +0330 Subject: [PATCH 25/29] Record kernel scheduling evidence for synchronized playback --- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index 8931781e..b743f1ed 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -29,3 +29,15 @@ That trace exposed roughly 400 KB event-log allocation at publication: the indep ci.1656 was verified and installed. Its short unprofiled replay restored and entered Playing, but show expired during a 29.4 ms write after earlier frame operations consumed the shared cue budget. Cleanup retained Stop/Stop, relays off, framing/CRC zero and the same reset count. No full run was claimed. The next diagnostic changes only the Windows idle serial read interval from 100 ms to 10 ms, keeping the persistent frame decoder and the original response/cue deadlines. This tests whether shorter blocking-read residency reduces driver/scheduler latency; it is a hypothesis, not a confirmed driver diagnosis. A fragmented ACK regression inserts empty reads between bytes to ensure timeout polling cannot discard frame state. No priority, GC or physical-output policy changes accompany it. + +## Kernel scheduling evidence + +ci.1657 passed all five hosted build targets and repository health, then was installed and independently live-identity verified. The settled paused comparison did not show a clear improvement: 40 fresh status calls at 100 ms read timeout had median/p95/max 15.60/30.60/41.90 ms; at 10 ms they measured 17.63/34.62/39.57 ms. Both had no errors or calls over 50 ms. Five-second idle CPU samples were about 0.025 and 0.037 CPU cores respectively; these short samples are descriptive, not a statistical performance claim. A separate reconnect-overlapping sample was excluded and retained privately. + +The unprofiled short replay entered Playing, but the first strip configure/stage/show reached cumulative ACK lateness 5.49/35.62/55.62 ms. The unchanged deadline interrupted playback. A subsequent paired Go/Windows scheduling trace failed an earlier restore: 54.3 ms total, including 39.3 ms in the write call. Both ended paused with seats stopped, relays off, and unchanged zero framing/CRC counters and reset count. + +The kernel trace explains why a long Go write duration is not necessarily a slow serial write. Relative to the Go trace synchronization point, the writer entered its overlapped wait at +208.406 ms. Windows readied that thread at +208.481 ms and scheduled it at +208.970 ms. Go then queued the goroutine as runnable, but it resumed at +247.223 ms. Separately, the serial reader's OS thread became ready at +212.004 ms and was not scheduled until +261.813 ms: approximately 49.81 ms of kernel ready delay. Its goroutine subsequently resumed at +277.79 ms. These observations establish scheduler contention in this captured failure; they do not establish physical byte arrival time or explain every prior failure. + +Across the four CPUs during that reader-ready interval, about 71.2 CPU-ms went to Pealayer, 53.1 to Remote Desktop Services, 21.3 to antimalware, and 20.5 to the controller. Only about 0.06 CPU-ms was idle. Higher dynamic priorities dominated much of the interval. No services, security controls, affinity, timer resolution or scheduling policy were disabled or persistently changed. The named 16 MB circular Windows capture stopped automatically after 50 seconds, merged successfully, and reported no lost events. The retained interval covers the failing request. ETL, decoded records and process-specific details remain private. + +This evidence does not support calling the shorter read timeout a driver fix. Next compare one bounded playback run with temporary host priority and automatic restoration; any production scheduling change needs its own tests and normal-load acceptance. Synchronized playback, exact-zero native seek, native modal visual checks, device installation and human output/audio acceptance remain open. From 86ada2a905067503a368eef98a51e47a8e50fdb7 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:52:59 +0330 Subject: [PATCH 26/29] Scope Windows playback priority to prepared serial ownership --- Tools/Controller/internal/link/session.go | 41 ++++++- .../link/timeline_reservation_test.go | 56 +++++++++ .../internal/playbackpriority/lease.go | 74 +++++++++++ .../internal/playbackpriority/lease_test.go | 116 ++++++++++++++++++ .../playbackpriority/priority_other.go | 5 + .../playbackpriority/priority_windows.go | 14 +++ docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 8 ++ 7 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 Tools/Controller/internal/playbackpriority/lease.go create mode 100644 Tools/Controller/internal/playbackpriority/lease_test.go create mode 100644 Tools/Controller/internal/playbackpriority/priority_other.go create mode 100644 Tools/Controller/internal/playbackpriority/priority_windows.go diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index de8a93ab..c3b2ca58 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log" "net" "net/url" "runtime" @@ -16,6 +17,7 @@ import ( "go.bug.st/serial" "pccontroller.local/controller/internal/native" + "pccontroller.local/controller/internal/playbackpriority" "pccontroller.local/controller/internal/portowner" ) @@ -85,6 +87,10 @@ type Session struct { rfWire rfWireState lastReadReturned atomic.Int64 lastDataReceived atomic.Int64 + // Real local serial sessions attach scheduling to their wire reservation. + // Test/adaptor sessions do not mutate the test runner's process priority. + acquirePlaybackPriority func() (func() error, error) + releaseTimeline func() // guarded by requestAdmission.mu } type sessionPort interface { @@ -176,10 +182,29 @@ func (admission *requestAdmission) acquire( func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { a := &s.requestAdmission a.mu.Lock() + if err := ctx.Err(); err != nil { + a.mu.Unlock() + return nil, err + } + select { + case <-s.closing: + a.mu.Unlock() + return nil, ErrClosed + default: + } if a.reserved { a.mu.Unlock() return nil, ErrTimelineReserved } + var releasePriority func() error + if s.acquirePlaybackPriority != nil { + var err error + releasePriority, err = s.acquirePlaybackPriority() + if err != nil { + a.mu.Unlock() + return nil, err + } + } a.reserved = true kept := a.waiters[:0] for _, waiter := range a.waiters { @@ -191,15 +216,22 @@ func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { } } a.waiters = kept - a.mu.Unlock() var once sync.Once release := func() { once.Do(func() { a.mu.Lock() a.reserved = false + s.releaseTimeline = nil + if releasePriority != nil { + if err := releasePriority(); err != nil { + log.Printf("playback scheduling cleanup: %v", err) + } + } a.mu.Unlock() }) } + s.releaseTimeline = release + a.mu.Unlock() drain, err := a.acquire(ctx, s.closing, RequestPriorityTimeline) if err != nil { release() @@ -345,6 +377,7 @@ func OpenContext(ctx context.Context, name string, baudRate int) (*Session, erro } session := NewForPort(name, port) + session.acquirePlaybackPriority = playbackpriority.Acquire return session, nil } @@ -818,6 +851,12 @@ func (s *Session) closeTransport() error { } s.notifyBeforeClose() s.closingOnce.Do(func() { close(s.closing) }) + s.requestAdmission.mu.Lock() + releaseTimeline := s.releaseTimeline + s.requestAdmission.mu.Unlock() + if releaseTimeline != nil { + releaseTimeline() + } if s.pendingOpen != nil { select { case result := <-s.pendingOpen.result: diff --git a/Tools/Controller/internal/link/timeline_reservation_test.go b/Tools/Controller/internal/link/timeline_reservation_test.go index f50c0834..502b7104 100644 --- a/Tools/Controller/internal/link/timeline_reservation_test.go +++ b/Tools/Controller/internal/link/timeline_reservation_test.go @@ -95,3 +95,59 @@ func TestTimelineReservationTimeoutDoesNotStrandAdmission(t *testing.T) { } release() } + +func TestTimelinePriorityLeaseFollowsReservationAndClose(t *testing.T) { + for _, closeFirst := range []bool{false, true} { + session := NewForPort("PRIORITY", newFakePort()) + acquired, released := 0, 0 + session.acquirePlaybackPriority = func() (func() error, error) { acquired++; return func() error { released++; return nil }, nil } + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + t.Fatal(err) + } + if acquired != 1 || released != 0 { + t.Fatal("priority not held by reservation") + } + if closeFirst { + if err = session.Close(); err != nil { + t.Fatal(err) + } + } + release() + release() + if err = session.Close(); err != nil { + t.Fatal(err) + } + if released != 1 { + t.Fatalf("priority released %d times", released) + } + } +} + +func TestTimelinePriorityFailureAndDrainTimeoutUnwind(t *testing.T) { + session := NewForPort("PRIORITY", newFakePort()) + defer session.Close() + failure := errors.New("priority denied") + session.acquirePlaybackPriority = func() (func() error, error) { return nil, failure } + if release, err := session.ReserveTimeline(context.Background()); release != nil || !errors.Is(err, failure) { + t.Fatalf("release present=%v error=%v", release != nil, err) + } + if session.requestAdmission.reserved { + t.Fatal("failed priority acquisition reserved wire") + } + released := 0 + session.acquirePlaybackPriority = func() (func() error, error) { return func() error { released++; return nil }, nil } + active, err := session.requestAdmission.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if _, err = session.ReserveTimeline(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + active() + if released != 1 || session.requestAdmission.reserved { + t.Fatal("drain timeout retained priority/wire") + } +} diff --git a/Tools/Controller/internal/playbackpriority/lease.go b/Tools/Controller/internal/playbackpriority/lease.go new file mode 100644 index 00000000..ff36ab4f --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/lease.go @@ -0,0 +1,74 @@ +// Package playbackpriority owns a temporary host scheduling lease, never a +// startup priority policy. Its lifetime is the prepared serial reservation. +package playbackpriority + +import ( + "fmt" + "sync" +) + +const ( + normalClass = uint32(0x20) + highClass = uint32(0x80) + realtimeClass = uint32(0x100) +) + +type manager struct { + mu sync.Mutex + get func() (uint32, error) + set func(uint32) error + refs int + previous uint32 + changed bool +} + +func (m *manager) acquire() (func() error, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.refs == 0 { + current, err := m.get() + if err != nil { + return nil, fmt.Errorf("read playback scheduling priority: %w", err) + } + // A failed previous restore retains its original baseline for retry. + if !m.changed || current != highClass { + m.changed = false + m.previous = current + } + if current != highClass && current != realtimeClass { + if err := m.set(highClass); err != nil { + return nil, fmt.Errorf("acquire playback scheduling priority: %w", err) + } + m.changed = true + } + } + m.refs++ + var once sync.Once + var releaseErr error + return func() error { + once.Do(func() { + m.mu.Lock() + defer m.mu.Unlock() + m.refs-- + if m.refs != 0 || !m.changed { + return + } + current, err := m.get() + if err != nil { + releaseErr = fmt.Errorf("inspect playback priority restoration: %w", err) + return + } + // Never overwrite a priority change made by another owner. + if current != highClass { + m.changed = false + return + } + if err := m.set(m.previous); err != nil { + releaseErr = fmt.Errorf("restore playback scheduling priority: %w", err) + return + } + m.changed = false + }) + return releaseErr + }, nil +} diff --git a/Tools/Controller/internal/playbackpriority/lease_test.go b/Tools/Controller/internal/playbackpriority/lease_test.go new file mode 100644 index 00000000..9b7d1d99 --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/lease_test.go @@ -0,0 +1,116 @@ +package playbackpriority + +import ( + "errors" + "reflect" + "testing" +) + +func TestLeaseRestoresOriginalOnlyAfterLastOwner(t *testing.T) { + current := normalClass + var writes []uint32 + m := manager{get: func() (uint32, error) { return current, nil }, set: func(p uint32) error { current = p; writes = append(writes, p); return nil }} + a, err := m.acquire() + if err != nil { + t.Fatal(err) + } + b, err := m.acquire() + if err != nil { + t.Fatal(err) + } + if err = a(); err != nil { + t.Fatal(err) + } + if err = a(); err != nil { + t.Fatal(err) + } + if current != highClass { + t.Fatal("first/duplicate release revoked the other owner") + } + if err = b(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(writes, []uint32{highClass, normalClass}) { + t.Fatalf("priority transitions %v", writes) + } +} + +func TestLeasePreservesExistingAndExternalPriorities(t *testing.T) { + for _, initial := range []uint32{highClass, realtimeClass} { + writes := 0 + m := manager{get: func() (uint32, error) { return initial, nil }, set: func(uint32) error { writes++; return nil }} + release, err := m.acquire() + if err != nil { + t.Fatal(err) + } + if err = release(); err != nil { + t.Fatal(err) + } + if writes != 0 { + t.Fatal("changed preexisting high/realtime policy") + } + } + current := normalClass + m := manager{get: func() (uint32, error) { return current, nil }, set: func(p uint32) error { current = p; return nil }} + release, err := m.acquire() + if err != nil { + t.Fatal(err) + } + current = 0x4000 // External owner selects BelowNormal during playback. + if err = release(); err != nil { + t.Fatal(err) + } + if current != 0x4000 { + t.Fatal("overwrote external priority change") + } +} + +func TestLeaseAcquisitionFailureDoesNotCreateOwner(t *testing.T) { + failure := errors.New("priority unavailable") + for _, readFailure := range []bool{true, false} { + m := manager{get: func() (uint32, error) { + if readFailure { + return 0, failure + } + return normalClass, nil + }, set: func(uint32) error { return failure }} + release, err := m.acquire() + if !errors.Is(err, failure) || release != nil || m.refs != 0 || m.changed { + t.Fatalf("failure leaked ownership: %+v, %v", m, err) + } + } +} + +func TestFailedRestoreRetainsOriginalBaselineForNextRelease(t *testing.T) { + current := normalClass + failure := errors.New("restore failed") + fail := true + m := manager{get: func() (uint32, error) { return current, nil }, set: func(p uint32) error { + if p == normalClass && fail { + return failure + } + current = p + return nil + }} + release, err := m.acquire() + if err != nil { + t.Fatal(err) + } + if err = release(); !errors.Is(err, failure) { + t.Fatal(err) + } + if !m.changed || m.refs != 0 { + t.Fatal("lost failed restoration ownership") + } + fail = false + release, err = m.acquire() + if err != nil { + t.Fatal(err) + } + if err = release(); err != nil { + t.Fatal(err) + } + if current != normalClass || m.changed { + t.Fatal("failed restore baseline was overwritten") + } +} diff --git a/Tools/Controller/internal/playbackpriority/priority_other.go b/Tools/Controller/internal/playbackpriority/priority_other.go new file mode 100644 index 00000000..bc08d0a4 --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/priority_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package playbackpriority + +func Acquire() (func() error, error) { return func() error { return nil }, nil } diff --git a/Tools/Controller/internal/playbackpriority/priority_windows.go b/Tools/Controller/internal/playbackpriority/priority_windows.go new file mode 100644 index 00000000..54d1bd31 --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/priority_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package playbackpriority + +import "golang.org/x/sys/windows" + +var processManager = manager{ + get: func() (uint32, error) { return windows.GetPriorityClass(windows.CurrentProcess()) }, + set: func(priority uint32) error { return windows.SetPriorityClass(windows.CurrentProcess(), priority) }, +} + +// Acquire raises only this process while at least one prepared serial owner +// holds the lease. It never requests realtime priority or changes OS policy. +func Acquire() (func() error, error) { return processManager.acquire() } diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index b743f1ed..1be20b54 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -41,3 +41,11 @@ The kernel trace explains why a long Go write duration is not necessarily a slow Across the four CPUs during that reader-ready interval, about 71.2 CPU-ms went to Pealayer, 53.1 to Remote Desktop Services, 21.3 to antimalware, and 20.5 to the controller. Only about 0.06 CPU-ms was idle. Higher dynamic priorities dominated much of the interval. No services, security controls, affinity, timer resolution or scheduling policy were disabled or persistently changed. The named 16 MB circular Windows capture stopped automatically after 50 seconds, merged successfully, and reported no lost events. The retained interval covers the failing request. ETL, decoded records and process-specific details remain private. This evidence does not support calling the shorter read timeout a driver fix. Next compare one bounded playback run with temporary host priority and automatic restoration; any production scheduling change needs its own tests and normal-load acceptance. Synchronized playback, exact-zero native seek, native modal visual checks, device installation and human output/audio acceptance remain open. + +## Reservation-scoped scheduling candidate + +The owned temporary High-priority comparison passed the short replay from 109.8 through 120.2 seconds: all 205 scheduled steps were acknowledged, maximum ACK lateness was 30.01 ms, maximum round trip 11.11 ms, and maximum restore ACK 4.00 ms. The entire replay was inside the measured priority window; finally restored Normal, paused playback and stopped outputs with unchanged counters. This is one successful diagnostic, not full continuous playback acceptance. + +The next candidate attaches a Windows process-priority lease to the existing prepared serial reservation. A real local serial session acquires it before draining/granting the wire. Failed acquisition refuses readiness; drain timeout, cancellation, ordinary release and connection close unwind it. The controller's existing reservation lifecycle covers pause, fault, expired paused preparation, replacement and shutdown. Renewing the same reservation does not create another priority owner. Multiple owners are reference-counted, duplicate releases are harmless, and the exact prior class is restored only after the last owner. Existing High/Realtime or an externally changed class is preserved; the implementation never requests Realtime. Restoration failures are logged separately and retain the original baseline for a later retry. + +The host remains at its original priority outside prepared playback ownership. No registry, service, timer-resolution or security settings change. Synthetic transport and priority tests use fake backends and do not raise the test runner's priority. Tests cover overlapping ownership, external changes, acquisition/restoration failures, drain timeout, duplicate release and connection-close cleanup. The full link suite and focused playback reservation/expiry/restore regressions are required before hosted build and exact-artifact deployment. A fresh normal-priority process must then pass the short and continuous real-media tests and return to Normal after cleanup. Windows scheduling remains best effort; this lease does not guarantee real-time execution or relax the original deadlines. From 30f95920dcd84f2634c8cec9c00f2a42913d0380 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:57:35 +0330 Subject: [PATCH 27/29] Keep priority restoration outside safety admission lock --- Tools/Controller/internal/link/session.go | 4 ++- .../link/timeline_reservation_test.go | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index c3b2ca58..45311a6e 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -222,12 +222,14 @@ func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { a.mu.Lock() a.reserved = false s.releaseTimeline = nil + a.mu.Unlock() + // Scheduling cleanup and logging must not block safety admission. + // The process manager retains ownership if another lease overlaps. if releasePriority != nil { if err := releasePriority(); err != nil { log.Printf("playback scheduling cleanup: %v", err) } } - a.mu.Unlock() }) } s.releaseTimeline = release diff --git a/Tools/Controller/internal/link/timeline_reservation_test.go b/Tools/Controller/internal/link/timeline_reservation_test.go index 502b7104..cd063692 100644 --- a/Tools/Controller/internal/link/timeline_reservation_test.go +++ b/Tools/Controller/internal/link/timeline_reservation_test.go @@ -151,3 +151,39 @@ func TestTimelinePriorityFailureAndDrainTimeoutUnwind(t *testing.T) { t.Fatal("drain timeout retained priority/wire") } } + +func TestTimelinePriorityCleanupDoesNotBlockSafetyAdmission(t *testing.T) { + session := NewForPort("PRIORITY", newFakePort()) + defer session.Close() + entered, resume := make(chan struct{}), make(chan struct{}) + session.acquirePlaybackPriority = func() (func() error, error) { + return func() error { close(entered); <-resume; return nil }, nil + } + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { release(); close(done) }() + <-entered + admitted := make(chan error, 1) + go func() { + releaseSafety, err := session.requestAdmission.acquire(context.Background(), session.closing, RequestPrioritySafety) + if releaseSafety != nil { + releaseSafety() + } + admitted <- err + }() + select { + case err := <-admitted: + close(resume) + <-done + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + close(resume) + <-done + t.Fatal("scheduling cleanup held safety admission") + } +} From 95d618d3a13e6e78606a14c1c6f7f4360b2a10d3 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:08:40 +0330 Subject: [PATCH 28/29] Avoid copying mutex in priority test failure diagnostic --- Tools/Controller/internal/playbackpriority/lease_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/Controller/internal/playbackpriority/lease_test.go b/Tools/Controller/internal/playbackpriority/lease_test.go index 9b7d1d99..d01640a8 100644 --- a/Tools/Controller/internal/playbackpriority/lease_test.go +++ b/Tools/Controller/internal/playbackpriority/lease_test.go @@ -76,7 +76,7 @@ func TestLeaseAcquisitionFailureDoesNotCreateOwner(t *testing.T) { }, set: func(uint32) error { return failure }} release, err := m.acquire() if !errors.Is(err, failure) || release != nil || m.refs != 0 || m.changed { - t.Fatalf("failure leaked ownership: %+v, %v", m, err) + t.Fatalf("failure leaked ownership: refs=%d changed=%v error=%v", m.refs, m.changed, err) } } } From 3f608a0b788c04d70d19d8510ce44003d4fdd3d0 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Fri, 30 Oct 2026 19:25:07 +0330 Subject: [PATCH 29/29] Record paired playback and retained integration gates --- docs/verification/pr-integration.md | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/verification/pr-integration.md diff --git a/docs/verification/pr-integration.md b/docs/verification/pr-integration.md new file mode 100644 index 00000000..2cbaaa69 --- /dev/null +++ b/docs/verification/pr-integration.md @@ -0,0 +1,57 @@ +# Strip, RF and programming source integration + +## Retained checkpoints + +The source integration branch retains these exact PR heads as ancestors, not +just copied patches or a provenance-only declaration: + +| PR | Head | Retained behavior | +| --- | --- | --- | +| #693 | `d9d0f8c91164757f2fb5d3f0dfecdbad824b95b3` | AVR strip operand constraints, linked waveform gate and late-frame pacing | +| #694 | `0b52df0caf5bc19122be44984c30aed233829f02` | Urclock-only adapter `nometadata`, without relaxing readback | +| #697 | `a48acbdf79ac0e409c19db2a78b78846bbb8fb27` | Verified-write restoration recovery and configured programmer backend propagation | +| #700 | `cdbeac91061d0a4404235723fda729fb432ac6f2` | Deferred RF decoding, bounded buffer ownership and AVR profile size tests | +| #701 | `95d618d3a13e6e78606a14c1c6f7f4360b2a10d3` | Prepared serial reservation, scoped scheduling, strip authoring and pixel diagnostics | + +The integration also retains main `8b0ea74a347f656640cfda244582802ac0d09517`: +`Project/ProtocolContract.json` remains the opcode owner, native A5/1 and Pakar +A5/0 admission is mandatory, and published opcode assignments remain append-only. +No legacy adapter or unknown-operation ACK fallback is introduced. + +## Paired playback contract + +`controller.media.playback.update` accepts `prepare_play` only for loaded, paused +media with a prepared plan revision. Starting a nonempty plan requires the +acknowledged epoch and `play_ready` serial reservation. Missing readiness fails +closed. Keep the existing 50 ms execution and 250 ms freshness/arming bounds. +Emergency stop retains priority admission while ordinary traffic is deferred; +intentional deferral is neither a successful observation nor a lost-board event. +This is the source counterpart of Pealayer's prepared-playback integration, not +evidence that either currently installed application has been replaced. + +## Hardware-free verification + +At integration checkpoint `085c209d38e88461398133ce3dc07a50bf3323cd`: + +- The canonical Windows stable-path runner passed all tests in the public facade, + controller command, host, control, link, native, playbackpriority, programmer + and UDP packages. Its selected-suite receipt is + `passed-selected-44b906be093a6770.json`; it is not the full-suite cache. +- `node Tools/Firmware/verify-rf-deferred.mjs` passed. +- Build-tooling and firmware source tests passed: 137 tests, no skips. +- Protocol regeneration, repository health and `git diff --check` passed. + +Applicable final-head Actions checks are still required before normal merge. +No aggregate, skipped job, historical CI run or prior deployed build substitutes +for those checks. + +## Explicit remaining acceptance + +This integration does not flash a board, replace an installed app, change a +setting or actuate an output. The historical readback, streaming and chair-only +receipts in the existing checkpoint documents retain their original scope. +Final-image physical RF/strip tests, Android installation and design acceptance, +native visual acceptance, broader effects and full-film endurance remain separate +gates. The upstream AVRDUDE 8.3 vector-padding interpretation and matching +adapter/configuration packaging must not be resolved by loosening verification. +Track remaining work in #696, #695, #562 and the paired Pealayer #80 as applicable.