From 6b861ad577baf5f88d6828d4c52413c835559f44 Mon Sep 17 00:00:00 2001 From: David Refoua Date: Wed, 14 Oct 2026 00:48:19 +0330 Subject: [PATCH 1/2] Verify RF MOSFET value and step assignments after Cafe delivery --- .../internal/control/pwm_actions_test.go | 46 +++++++++++++++++++ .../internal/ipcjson/rf_bindings_test.go | 38 +++++++++++++++ docs/RF-APPLICATION-ACTIONS.md | 44 ++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/Tools/Controller/internal/control/pwm_actions_test.go b/Tools/Controller/internal/control/pwm_actions_test.go index 1f820aaa..ea040026 100644 --- a/Tools/Controller/internal/control/pwm_actions_test.go +++ b/Tools/Controller/internal/control/pwm_actions_test.go @@ -253,3 +253,49 @@ func TestPWMActionFramedTransportReadbackAndReplacement(t *testing.T) { }) } } + +func TestPWMAbsoluteCommandUsesConfiguredCurveAndRejectsInvalidPercent(t *testing.T) { + for _, curve := range []string{"linear", "gamma"} { + for _, text := range []string{"0", "12.5", "35", "100", "-1", "100.1", "NaN", "+Inf"} { + t.Run(curve+"/"+text, func(t *testing.T) { + runtime := New(Options{RequestTimeout: time.Second}) + t.Cleanup(func() { _ = runtime.Close() }) + config := appconfig.Defaults() + channel := appconfig.PWMChannel(config.UI, 0) + channel.Curve, channel.Gamma = curve, 2.2 + config.UI.PWMChannels[appconfig.PWMChannelKey(0)] = channel + var mu sync.Mutex + writes := 0 + var actual uint16 + port := &pwmActionTestPort{reconnectTestPort: newReconnectTestPort(), replies: make(chan []byte, 4)} + port.handle = func(frame native.Frame) (native.Frame, error) { + mu.Lock() + defer mu.Unlock() + if frame.Opcode != native.OpPWMSet || len(frame.Payload) != 3 || frame.Payload[0] != 0 { + return native.Frame{}, errors.New("unexpected PWM command frame") + } + writes++ + actual = binary.LittleEndian.Uint16(frame.Payload[1:]) + return native.Frame{Opcode: native.OpACK, Payload: []byte{native.OpPWMSet, 0}}, nil + } + session := link.NewForPort("fixture", port) + runtime.session, runtime.generation = session, 7 + t.Cleanup(func() { _ = session.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err := pwmCommand(ctx, runtime, CommandOptions{HostConfig: func() appconfig.Config { return config }}, []string{"set", "0", text}) + percent, _ := strconv.ParseFloat(text, 64) + valid := !math.IsNaN(percent) && !math.IsInf(percent, 0) && percent >= 0 && percent <= 100 + mu.Lock() + defer mu.Unlock() + if !valid { + if err == nil || writes != 0 { + t.Fatalf("invalid percent reached transport: %q writes=%d err=%v", text, writes, err) + } + } else if err != nil || writes != 1 || actual != appconfig.PWMRawFromPercent(percent, channel) { + t.Fatalf("absolute percent conversion/write failed: %q curve=%s raw=%d writes=%d err=%v", text, curve, actual, writes, err) + } + }) + } + } +} diff --git a/Tools/Controller/internal/ipcjson/rf_bindings_test.go b/Tools/Controller/internal/ipcjson/rf_bindings_test.go index 3b202202..5e3beefb 100644 --- a/Tools/Controller/internal/ipcjson/rf_bindings_test.go +++ b/Tools/Controller/internal/ipcjson/rf_bindings_test.go @@ -124,6 +124,44 @@ func TestRFBindingAdvertisedPWMControlCanBeSavedWithoutActuation(t *testing.T) { } } +func TestRFBindingPWMAbsoluteValueAndRelativeStepsPreserveIntent(t *testing.T) { + for _, command := range []string{"pwm set 0 0", "pwm set 0 12.5", "pwm set 0 35", "pwm set 0 100"} { + t.Run(command, func(t *testing.T) { + service, config := rfBindingTestService(t) + step := 2.5 + channel := appconfig.PWMChannel(config.UI, 0) + channel.StepPercent = &step + config.UI.PWMChannels[appconfig.PWMChannelKey(0)] = channel + code := uint32(42) + rule := appconfig.Automation{Name: "MOSFET", Enabled: true, + Match: appconfig.AutomationMatch{Kind: "rf.gesture", Source: "rf", RFCode: &code, RFBits: 24, RFProtocol: 1, Gesture: "down"}, + Actions: []appconfig.AutomationAction{ + {Type: "board", Command: command}, + {Type: "control", ActionID: "pwm.0.increase"}, + {Type: "control", ActionID: "pwm.0.decrease"}, + }, + } + raw, _ := json.Marshal(map[string]any{"binding": rule}) + if _, err := service.putRFBinding(raw); err != nil { + t.Fatal(err) + } + if len(config.Automations) != 1 { + t.Fatal("RF rule was not saved exactly once") + } + got := config.Automations[0].Actions + if len(got) != 3 || got[0].Command != command || got[1].ActionID != "pwm.0.increase" || got[2].ActionID != "pwm.0.decrease" { + t.Fatalf("RF value or ordered relative actions changed: %+v", got) + } + if appconfig.PWMStepPercent(appconfig.PWMChannel(config.UI, 0)) != step { + t.Fatal("RF assignment rewrote channel step") + } + if service.Client.Snapshot().Connected { + t.Fatal("saving an assignment connected or actuated a board") + } + }) + } +} + func TestRFBindingKeyboardRequiresExplicitPolicyConsent(t *testing.T) { service, config := rfBindingTestService(t) raw := json.RawMessage(`{"binding":{"name":"Keyboard","enabled":true,"match":{"kind":"rf.gesture","source":"rf","rf_code":12345,"rf_bits":24,"rf_protocol":1,"gesture":"down"},"actions":[{"type":"virtual-key","virtual_key":"F23","hold_ms":50}]}}`) diff --git a/docs/RF-APPLICATION-ACTIONS.md b/docs/RF-APPLICATION-ACTIONS.md index f04ac672..1cc956db 100644 --- a/docs/RF-APPLICATION-ACTIONS.md +++ b/docs/RF-APPLICATION-ACTIONS.md @@ -47,6 +47,22 @@ process start, not that the external program completed its own work. ### MOSFET percentage assignments +To assign an arbitrary level, use the existing host-owned **Board command** +action with `pwm set CHANNEL PERCENT`, for example `pwm set 0 35` for 35%. +The persisted action is `{"type":"board","command":"pwm set 0 35"}`. +Channel numbers are the advertised zero-based PWM IDs, not relay numbers; +use the channel's current renamable title when choosing it. Values accept +0โ€“100, including fractional percentages, and use the channel's configured +linear/gamma curve. `pwm raw CHANNEL DUTY` is the separate 0โ€“4095 expert path, +not a percentage. Saving a binding does not execute its actions. + +For relative actions, choose the advertised Increase/Decrease control action, +for example `{"type":"control","action_id":"pwm.0.increase"}` or +`pwm.0.decrease`. Both retain the configured channel step and shared safety, +serialization and authoritative-readback handling. The `board` action type +means a command sent to the board by the host; it does not imply autonomous +EEPROM execution when the host is stopped. + Choose a channel from the advertised control list; do not infer channel numbers from a 0โ€“100 brightness range. MOSFET controls advertise `pwm.N.on`, `.off`, `.increase` and `.decrease`. On requests 100% logical intensity, Off requests @@ -147,6 +163,34 @@ storage views of the same RF button. Neither API silently modifies the other. Wire acknowledgements do not prove over-the-air delivery to a physical receiver; real handset/receiver/transmitter validation remains a physical acceptance gate. +## Cafe deployment and small-AVR fit check ยท 14 October 2026 + +The requested deployment-first pass installed current main source +`0f067a2f3d02c8d8a9779b2c3e29ee4f81a4208c` using the primary-owned updater. +Operation `op-3cf502807cf36e56` acknowledged restart/health with +`terminal_verified=true`; executable SHA-256 is +`8cd71d69c5da513160ecc2558fabbc6b8b57407ca636ef1f88ecb5f51f579ff6`. +The live cached RF catalog is connected and advertises Increase/Decrease for +current names including Window, Loostr and Garden. Settings and learned RF +records were not edited, and no output, RF transmission or firmware was flashed. +Post-deployment PWM/RF/configuration suites passed through the stable Go runner; +fresh five-target/native/AVR CI was dispatched as run `38467880758`. + +Board ownership was assessed, not assumed. The full default AVR manifest has +68 flash bytes before the reserved identity at `0x7e74`. A compact RF set/step +prototype reusing the existing 8-to-12-bit expansion overflowed that boundary +by 62 bytes. A second prototype preserved the existing twelve-byte RF record: +its two mapping bytes packed a four-bit channel and twelve-bit raw value/step, +and reused `adjustValue` for bounded relative changes. That build's data load +range `0x7e50..0x7ed3` also overlapped identity `0x7e74..0x7e7f` (96 bytes past +the allowed start). Neither is a production implementation or a proof that all +possible future optimizations are impossible. Both failed trials were restored; +the working shared production firmware, EEPROM layout and existing mappings +remain unchanged. A larger-flash target could be assessed separately; it has not +been built or flashed with those trials. Do not remove firmware features or move +the identity/bootloader to claim a fit. Host-owned curve-aware assignments remain +the delivered choice for Cafe, with physical RF/actuator acceptance still open. + ## Integration acceptance checkpoint The 6 October 2026 local installation uses host revision `3486fc1a` and Pealayer From 4f71223ad4e5bcfac7ba6634c20eb00111f7972c Mon Sep 17 00:00:00 2001 From: David Refoua Date: Wed, 14 Oct 2026 13:23:15 +0330 Subject: [PATCH 2/2] Model shared semantic Off readback in absolute PWM fixture --- .../internal/control/pwm_actions_test.go | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Tools/Controller/internal/control/pwm_actions_test.go b/Tools/Controller/internal/control/pwm_actions_test.go index 783fc09a..084d05c4 100644 --- a/Tools/Controller/internal/control/pwm_actions_test.go +++ b/Tools/Controller/internal/control/pwm_actions_test.go @@ -265,12 +265,22 @@ func TestPWMAbsoluteCommandUsesConfiguredCurveAndRejectsInvalidPercent(t *testin channel.Curve, channel.Gamma = curve, 2.2 config.UI.PWMChannels[appconfig.PWMChannelKey(0)] = channel var mu sync.Mutex - writes := 0 + writes, reads := 0, 0 var actual uint16 port := &pwmActionTestPort{reconnectTestPort: newReconnectTestPort(), replies: make(chan []byte, 4)} port.handle = func(frame native.Frame) (native.Frame, error) { mu.Lock() defer mu.Unlock() + // Absolute zero uses the shared semantic Off read/write/read + // path so brightness recall is preserved; positive absolute + // values still have one direct framed write. + if frame.Opcode == native.OpPWMGet && len(frame.Payload) == 0 { + reads++ + payload := make([]byte, 34) + payload[0] = 1 + binary.LittleEndian.PutUint16(payload[2:], actual) + return native.Frame{Opcode: native.OpPWMValues, Payload: payload}, nil + } if frame.Opcode != native.OpPWMSet || len(frame.Payload) != 3 || frame.Payload[0] != 0 { return native.Frame{}, errors.New("unexpected PWM command frame") } @@ -289,12 +299,19 @@ func TestPWMAbsoluteCommandUsesConfiguredCurveAndRejectsInvalidPercent(t *testin mu.Lock() defer mu.Unlock() if !valid { - if err == nil || writes != 0 { + if err == nil || writes != 0 || reads != 0 { t.Fatalf("invalid percent reached transport: %q writes=%d err=%v", text, writes, err) } } else if err != nil || writes != 1 || actual != appconfig.PWMRawFromPercent(percent, channel) { t.Fatalf("absolute percent conversion/write failed: %q curve=%s raw=%d writes=%d err=%v", text, curve, actual, writes, err) } + wantReads := 0 + if valid && percent == 0 { + wantReads = 2 + } + if reads != wantReads { + t.Fatalf("absolute value readback count = %d, want %d", reads, wantReads) + } }) } }