Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
63 changes: 63 additions & 0 deletions Tools/Controller/internal/control/pwm_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,66 @@ 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, 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")
}
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 || 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)
}
})
}
}
}
38 changes: 38 additions & 0 deletions Tools/Controller/internal/ipcjson/rf_bindings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}]}}`)
Expand Down
44 changes: 44 additions & 0 deletions docs/RF-APPLICATION-ACTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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`,
`.toggle`, `.increase` and `.decrease`. On defaults to 100% logical intensity;
Expand Down Expand Up @@ -164,6 +180,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
Expand Down
Loading