Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
93f249c
feat: integrate strip designer and deliberate pixel diagnostics
DRSDavidSoft Oct 26, 2026
0e0133d
fix: compare numeric strip preview phase without integer-only getter
DRSDavidSoft Oct 26, 2026
2c4fe64
feat: expose strip lighting creation from the effect library
DRSDavidSoft Oct 26, 2026
65f4584
test: expect supported strip effect command
DRSDavidSoft Oct 26, 2026
1275716
fix: align Flutter flash preview with host phase
DRSDavidSoft Oct 26, 2026
83991c5
fix: fence PWM readback during prepared playback
DRSDavidSoft Oct 26, 2026
c5ff502
fix: reserve serial channel before prepared playback
DRSDavidSoft Oct 26, 2026
af8f7ca
fix: preserve safety access during serial reservation
DRSDavidSoft Oct 26, 2026
4437f06
Classify playback deferrals and retain serial timeout timing
DRSDavidSoft Oct 26, 2026
63a2314
Trace executor scheduling and enforce restore ACK deadline
DRSDavidSoft Oct 26, 2026
4c17b97
Document bounded execution trace in API catalog
DRSDavidSoft Oct 26, 2026
e3710fc
Avoid effect catalog expansion in live board observers
DRSDavidSoft Oct 27, 2026
16cd25a
Yield delivered serial replies before blocking reads and reduce event…
DRSDavidSoft Oct 27, 2026
4dbdd17
Reuse bounded event log storage on timeline publication
DRSDavidSoft Oct 27, 2026
2857bf2
Bound Windows idle serial reads to ten milliseconds
DRSDavidSoft Oct 27, 2026
6233453
Record kernel scheduling evidence for synchronized playback
DRSDavidSoft Oct 27, 2026
86ada2a
Scope Windows playback priority to prepared serial ownership
DRSDavidSoft Oct 27, 2026
30f9592
Keep priority restoration outside safety admission lock
DRSDavidSoft Oct 27, 2026
95d618d
Avoid copying mutex in priority test failure diagnostic
DRSDavidSoft Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Tools/Audit/generate-api-reference.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
47 changes: 47 additions & 0 deletions Tools/Controller/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions Tools/Controller/api/reference.html

Large diffs are not rendered by default.

34 changes: 29 additions & 5 deletions Tools/Controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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) {
Expand Down Expand Up @@ -2330,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()
Expand All @@ -2344,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,
Expand Down
8 changes: 7 additions & 1 deletion Tools/Controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions Tools/Controller/internal/control/board_liveness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
3 changes: 3 additions & 0 deletions Tools/Controller/internal/control/emergency_stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions Tools/Controller/internal/control/event_log_reuse_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
24 changes: 12 additions & 12 deletions Tools/Controller/internal/control/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,31 +320,31 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) {
statusStart++
}
if statusStart != 0 {
runtime.statusHistory = append(
[]StatusSample(nil),
runtime.statusHistory[statusStart:]...,
)
runtime.statusHistory = retainSliceSuffix(runtime.statusHistory, statusStart)
}
timelineStart := 0
for timelineStart < len(runtime.timeline) &&
runtime.timeline[timelineStart].Time.Before(cutoff) {
timelineStart++
}
if timelineStart != 0 {
runtime.timeline = append(
[]TimelineEntry(nil),
runtime.timeline[timelineStart:]...,
)
runtime.timeline = retainSliceSuffix(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 = retainSliceSuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit)
}
}

// 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 retainSliceSuffix[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
Expand Down
26 changes: 26 additions & 0 deletions Tools/Controller/internal/control/history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
6 changes: 6 additions & 0 deletions Tools/Controller/internal/control/lcd_presenter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package control

import (
"context"
"errors"
"fmt"
"strings"
"sync"
Expand Down Expand Up @@ -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())
Expand Down
30 changes: 30 additions & 0 deletions Tools/Controller/internal/control/lcd_presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
8 changes: 6 additions & 2 deletions Tools/Controller/internal/control/macros.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading