Skip to content

Commit 2842a86

Browse files
committed
feat(companion): add Wake-on-LAN configuration commands and UI
Wires the firmware WOL module up to the companion protocol and app: - New COMMAND_ID values (SET_WOL_ENABLED, SET_WOL_WIFI_SSID, SET_WOL_WIFI_PASSWORD, SET_WOL_TARGET_MAC) at 0x46-0x49, placed after the existing SET_EDGE_PROFILE_SWITCHING_BLOCKED (0x45) so they don't collide with any currently-assigned command ID. PROTOCOL_MINOR bumped to 23 for the new commands and status flag. - src/companion.cpp: command handlers for the four new commands, plus a wolControl status flag (protocol-minor gated) so the companion app can tell whether the connected firmware supports WOL at all. - Companion app: new Wake-on-LAN section in Bridge Settings with an enable toggle, Wi-Fi SSID/password fields, and a target MAC address field, wired through preload/IPC/bridge-service the same way existing settings are. Hidden entirely on firmware without ENABLE_WOLWIFI (i.e. wolControl false). Both sides were verified against each other end-to-end on real hardware: controller connects while the target PC is off -> WOL fires, gets ARP-confirmed, PC wakes, controller stays connected through the boot.
1 parent 1172746 commit 2842a86

13 files changed

Lines changed: 587 additions & 48 deletions

File tree

README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ on the controller until its lightbar rapidly blinks blue.
7575
- Switch the host persona between DualSense, DualSense Edge, DualShock 4, and Xbox modes.
7676
- See Bluetooth signal quality at a glance.
7777
- Mount, flash, or nuke Pico firmware from Bridge Settings.
78+
- Wake your PC over Wi-Fi (Wake-on-LAN) just by connecting your controller
79+
(Waveshare RP2350B-Plus-W only).
7880

7981
## Explore Kitsune Input
8082

@@ -187,12 +189,70 @@ saved but inactive.
187189
### Bridge Settings
188190

189191
Set theme, UI scale, tray and startup behavior, firmware maintenance, power
190-
saving, LEDs, shortcuts, idle disconnect, and PC sleep disconnect.
192+
saving, LEDs, shortcuts, idle disconnect, PC sleep disconnect, and Wake-on-LAN.
191193

192194
<p align="center">
193195
<img src="assets/readme/app-bridge-settings.png" width="680" alt="Bridge Settings dialog in the DS5 Bridge companion app">
194196
</p>
195197

198+
## Wake-on-LAN
199+
200+
On the Waveshare RP2350B-Plus-W board, DS5 Bridge can wake your PC over Wi-Fi
201+
the moment your DualSense controller connects — no keyboard, mouse, or
202+
Ethernet magic-packet utility needed. This is a separate mechanism from the
203+
existing USB-based "Wake PC on Controller" toggle: that one relies on USB
204+
remote wake and only works while the Pico stays connected to a PC that is
205+
merely asleep, not fully powered off. Wake-on-LAN instead sends a real network
206+
magic packet, so it can wake a PC that's been shut down, as long as the PC's
207+
network adapter has Wake-on-LAN enabled.
208+
209+
### How it works
210+
211+
1. When your controller connects to the Pico, the firmware first checks
212+
whether the target PC already looks awake over USB (so it never sends an
213+
unnecessary wake packet, or interrupts you with a lightbar pulse, while
214+
you're already at your desk with everything on).
215+
2. If the PC looks like it needs waking, the Pico connects to your Wi-Fi
216+
network using the CYW43 radio it already has onboard for Bluetooth, and
217+
sends a standard UDP Wake-on-LAN magic packet addressed to your PC's MAC
218+
address.
219+
3. The firmware watches for the target to come back on the network (via ARP)
220+
and resends the magic packet a few times if needed, so a single dropped
221+
packet on a slow-to-associate Wi-Fi network doesn't leave your PC asleep.
222+
4. All of this runs alongside normal Bluetooth/controller operation. A
223+
missing Wi-Fi network, wrong credentials, or an unreachable target PC never
224+
blocks or delays controller pairing, input, or audio — Wake-on-LAN is
225+
strictly best-effort.
226+
227+
### Setup
228+
229+
1. Open **Bridge Settings** in the companion app and find the **Wake-on-LAN**
230+
section.
231+
2. Turn on **Wake PC over Wi-Fi**.
232+
3. Enter the **Wi-Fi Network** name (SSID) and **Wi-Fi Password** the Pico
233+
should use to reach your PC's network. This can be the same network your
234+
PC is on, or any network that can route a magic packet to it.
235+
4. Enter your PC's **Target MAC Address** (`AA:BB:CC:DD:EE:FF`) — use the MAC
236+
address of the network adapter you want to wake, not necessarily the one
237+
currently active.
238+
5. In Windows, make sure Wake-on-LAN is enabled for that network adapter
239+
(Device Manager → adapter **Properties****Power Management***Allow
240+
this device to wake the computer*, and, for wired adapters, the matching
241+
*Wake on Magic Packet* option in **Advanced**) and that fast startup is
242+
configured in a way that's compatible with your adapter's wake support.
243+
244+
### Requirements
245+
246+
- Waveshare RP2350B-Plus-W board. Wake-on-LAN firmware builds are gated
247+
behind this board at compile time (not because other CYW43-equipped
248+
boards like Pico W/Pico 2 W lack the radio — it's only been built and
249+
tested on Waveshare so far); other boards simply don't show the
250+
Wake-on-LAN section in the app.
251+
- A Wi-Fi network that can reach your PC's network segment.
252+
- Wake-on-LAN enabled on the target PC's network adapter and in its BIOS/UEFI
253+
power settings, and the adapter's wake support has to survive a full
254+
shutdown (not just sleep) for the "wake from off" case.
255+
196256
## Troubleshooting
197257

198258
- Use the companion app and firmware from the same release when possible.
@@ -233,6 +293,7 @@ steps.
233293
| `src/companion.cpp` | Vendor HID companion protocol, status reports, command ACKs, and runtime setting dispatch. |
234294
| `src/usb.cpp` | TinyUSB audio control callbacks and runtime settings fallback. |
235295
| `src/usb_descriptors.c` | USB device, configuration, HID report, audio, and string descriptors. |
296+
| `src/wolwifi.cpp` | Wake-on-LAN over Wi-Fi: host-alive gate, Wi-Fi connect, and magic-packet send/resend (Waveshare RP2350B-Plus-W only). |
236297
| `companion/` | Electron companion app source, protocol parser, HID service, assets, and UI. |
237298
| `companion/native/AudioHelper/` | Windows audio helper used by the companion app for audio sessions, haptics mirroring, endpoint setup, and media integrations. |
238299
| `.github/workflows` | CI and release builds. |

companion/src/main/bridge-service.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const FULL_REAPPLY_COMMANDS = [
143143
COMMAND_ID.SET_IDLE_DISCONNECT_TIMEOUT,
144144
COMMAND_ID.SET_USB_SUSPEND_DISCONNECT_ENABLED,
145145
COMMAND_ID.SET_WAKE_ON_CONNECT,
146+
COMMAND_ID.SET_WOL_ENABLED,
146147
COMMAND_ID.SET_SLEEP_KEYBIND_ENABLED,
147148
COMMAND_ID.SET_SPEAKER_VOLUME_SHORTCUT_ENABLED,
148149
COMMAND_ID.SET_BUTTON_REMAP,
@@ -2696,6 +2697,33 @@ describe('BridgeService', () => {
26962697
expect(commands[2]?.slice(11, 17)).toEqual([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
26972698
});
26982699

2700+
it('sends the SSID/password byte length in the command value, not 0', async () => {
2701+
// Regression test: setWolWifiSsid/setWolWifiPassword used to hardcode
2702+
// the command's value field to 0 instead of the payload length, so
2703+
// firmware (which reads value as the byte count to copy) always saw a
2704+
// zero-length string and silently kept have_ssid=false no matter what
2705+
// was typed in the UI -- Wi-Fi never got the actual SSID.
2706+
const service = serviceFixture();
2707+
const device = new MockHidDevice();
2708+
device.status = statusReport({ controllerConnected: false });
2709+
hidMock.state.devicesList = [companionDeviceInfo()];
2710+
hidMock.state.openDevices.set('companion-path', device);
2711+
2712+
await poll(service);
2713+
await service.setWolWifiSsid('Sweet-Home');
2714+
await service.setWolWifiPassword('correct horse battery staple');
2715+
2716+
const ssidReport = device.sentReports.find((report) => report[7] === COMMAND_ID.SET_WOL_WIFI_SSID);
2717+
const passwordReport = device.sentReports.find((report) => report[7] === COMMAND_ID.SET_WOL_WIFI_PASSWORD);
2718+
2719+
expect(ssidReport?.[9]).toBe('Sweet-Home'.length);
2720+
expect(ssidReport?.slice(11, 11 + 'Sweet-Home'.length).map((byte) => String.fromCharCode(byte)).join('')).toBe('Sweet-Home');
2721+
2722+
const password = 'correct horse battery staple';
2723+
expect(passwordReport?.[9]).toBe(password.length);
2724+
expect(passwordReport?.slice(11, 11 + password.length).map((byte) => String.fromCharCode(byte)).join('')).toBe(password);
2725+
});
2726+
26992727
it('sends Pico bootloader command and tolerates the expected transport drop', async () => {
27002728
const service = serviceFixture();
27012729
const device = new MockHidDevice();

companion/src/main/bridge-service.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ import {
4040
hostPersonaModeValue,
4141
normalizeChordControllerSettingStepPercent,
4242
normalizeBridgePresetId,
43-
pollingRateModeValue
43+
pollingRateModeValue,
44+
wolTargetMacPayload,
45+
wolWifiPasswordPayload,
46+
wolWifiSsidPayload
4447
} from '../shared/protocol';
4548
import type {
4649
AdaptiveTriggerPreviewEffect,
@@ -1304,7 +1307,9 @@ export class BridgeService extends EventEmitter {
13041307
[SHORTCUT_EVENT.MIC_MUTE_OFF]: () => this.applyControllerMicMuteEvent(false)
13051308
};
13061309

1307-
constructor(private readonly settingsStore: SettingsStore) {
1310+
constructor(
1311+
private readonly settingsStore: SettingsStore
1312+
) {
13081313
super();
13091314
this.snapshot = {
13101315
state: 'no-bridge',
@@ -2954,6 +2959,45 @@ export class BridgeService extends EventEmitter {
29542959
return this.getSnapshot();
29552960
}
29562961

2962+
async setWolEnabled(enabled: boolean): Promise<BridgeSnapshot> {
2963+
await this.sendSettingCommand(COMMAND_ID.SET_WOL_ENABLED, enabled ? 1 : 0, {
2964+
wolEnabled: enabled
2965+
});
2966+
return this.getSnapshot();
2967+
}
2968+
2969+
async setWolWifiSsid(ssid: string): Promise<BridgeSnapshot> {
2970+
const payload = wolWifiSsidPayload(ssid);
2971+
await this.sendSettingCommand(
2972+
COMMAND_ID.SET_WOL_WIFI_SSID,
2973+
payload.length,
2974+
{ wolWifiSsid: ssid },
2975+
payload
2976+
);
2977+
return this.getSnapshot();
2978+
}
2979+
2980+
async setWolWifiPassword(password: string): Promise<BridgeSnapshot> {
2981+
const payload = wolWifiPasswordPayload(password);
2982+
await this.sendSettingCommand(
2983+
COMMAND_ID.SET_WOL_WIFI_PASSWORD,
2984+
payload.length,
2985+
{ wolWifiPassword: password },
2986+
payload
2987+
);
2988+
return this.getSnapshot();
2989+
}
2990+
2991+
async setWolTargetMac(mac: string): Promise<BridgeSnapshot> {
2992+
await this.sendSettingCommand(
2993+
COMMAND_ID.SET_WOL_TARGET_MAC,
2994+
0,
2995+
{ wolTargetMac: mac },
2996+
wolTargetMacPayload(mac)
2997+
);
2998+
return this.getSnapshot();
2999+
}
3000+
29573001
async setSleepKeybindEnabled(enabled: boolean): Promise<BridgeSnapshot> {
29583002
await this.sendSettingCommand(COMMAND_ID.SET_SLEEP_KEYBIND_ENABLED, enabled ? 1 : 0, {
29593003
sleepKeybindEnabled: enabled
@@ -4040,6 +4084,31 @@ export class BridgeService extends EventEmitter {
40404084
settings.wakeOnConnectEnabled ? 1 : 0,
40414085
{ expectSettingsRevisionChange }
40424086
);
4087+
await this.sendCommand(
4088+
COMMAND_ID.SET_WOL_ENABLED,
4089+
settings.wolEnabled ? 1 : 0,
4090+
{ expectSettingsRevisionChange }
4091+
);
4092+
if (settings.wolWifiSsid) {
4093+
const ssidPayload = wolWifiSsidPayload(settings.wolWifiSsid);
4094+
await this.sendCommand(COMMAND_ID.SET_WOL_WIFI_SSID, ssidPayload.length, {
4095+
expectSettingsRevisionChange,
4096+
extraPayload: ssidPayload
4097+
});
4098+
}
4099+
if (settings.wolWifiPassword) {
4100+
const passwordPayload = wolWifiPasswordPayload(settings.wolWifiPassword);
4101+
await this.sendCommand(COMMAND_ID.SET_WOL_WIFI_PASSWORD, passwordPayload.length, {
4102+
expectSettingsRevisionChange,
4103+
extraPayload: passwordPayload
4104+
});
4105+
}
4106+
if (settings.wolTargetMac) {
4107+
await this.sendCommand(COMMAND_ID.SET_WOL_TARGET_MAC, 0, {
4108+
expectSettingsRevisionChange,
4109+
extraPayload: wolTargetMacPayload(settings.wolTargetMac)
4110+
});
4111+
}
40434112
await this.sendCommand(
40444113
COMMAND_ID.SET_SLEEP_KEYBIND_ENABLED,
40454114
settings.sleepKeybindEnabled ? 1 : 0,

companion/src/main/ipc-contract.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,19 @@ describe('IPC contract', () => {
139139
expect(mainSource).toContain("ipcMain.handle('bridge:setWakeOnConnectEnabled'");
140140
expect(bridgeServiceSource).toContain('setWakeOnConnectEnabled(enabled: boolean): Promise<BridgeSnapshot>');
141141
});
142+
143+
it('exposes the Wake-on-LAN over Wi-Fi preferences', () => {
144+
expect(preloadSource).toContain("ipcRenderer.invoke('bridge:setWolEnabled', value)");
145+
expect(preloadSource).toContain("ipcRenderer.invoke('bridge:setWolWifiSsid', value)");
146+
expect(preloadSource).toContain("ipcRenderer.invoke('bridge:setWolWifiPassword', value)");
147+
expect(preloadSource).toContain("ipcRenderer.invoke('bridge:setWolTargetMac', value)");
148+
expect(mainSource).toContain("ipcMain.handle('bridge:setWolEnabled'");
149+
expect(mainSource).toContain("ipcMain.handle('bridge:setWolWifiSsid'");
150+
expect(mainSource).toContain("ipcMain.handle('bridge:setWolWifiPassword'");
151+
expect(mainSource).toContain("ipcMain.handle('bridge:setWolTargetMac'");
152+
expect(bridgeServiceSource).toContain('async setWolEnabled(enabled: boolean): Promise<BridgeSnapshot>');
153+
expect(bridgeServiceSource).toContain('async setWolWifiSsid(ssid: string): Promise<BridgeSnapshot>');
154+
expect(bridgeServiceSource).toContain('async setWolWifiPassword(password: string): Promise<BridgeSnapshot>');
155+
expect(bridgeServiceSource).toContain('async setWolTargetMac(mac: string): Promise<BridgeSnapshot>');
156+
});
142157
});

companion/src/main/main.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,18 @@ function registerIpc(service: BridgeService): void {
11381138
ipcMain.handle('bridge:setWakeOnConnectEnabled', (_event, value: boolean) => (
11391139
service.setWakeOnConnectEnabled(value)
11401140
));
1141+
ipcMain.handle('bridge:setWolEnabled', (_event, value: boolean) => (
1142+
service.setWolEnabled(value)
1143+
));
1144+
ipcMain.handle('bridge:setWolWifiSsid', (_event, value: string) => (
1145+
service.setWolWifiSsid(value)
1146+
));
1147+
ipcMain.handle('bridge:setWolWifiPassword', (_event, value: string) => (
1148+
service.setWolWifiPassword(value)
1149+
));
1150+
ipcMain.handle('bridge:setWolTargetMac', (_event, value: string) => (
1151+
service.setWolTargetMac(value)
1152+
));
11411153
ipcMain.handle('bridge:setSleepKeybindEnabled', (_event, value: boolean) => (
11421154
service.setSleepKeybindEnabled(value)
11431155
));

companion/src/main/settings-store.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
MAX_CHORD_FUNCTION_NAME_LENGTH,
1212
MAX_KEYBOARD_FUNCTION_KEYS,
1313
REMAP_BUTTON_IDS,
14+
WOL_WIFI_PASSWORD_MAX_LENGTH,
15+
WOL_WIFI_SSID_MAX_LENGTH,
1416
clampAudioInterleaveValues,
1517
defaultChordControllerSettingStepPercent,
1618
isChordBindingAllowed,
@@ -200,6 +202,10 @@ export const DEFAULT_SETTINGS: CompanionSettings = {
200202
idleDisconnectTimeoutMinutes: 15,
201203
usbSuspendDisconnectEnabled: true,
202204
wakeOnConnectEnabled: true,
205+
wolEnabled: false,
206+
wolWifiSsid: '',
207+
wolWifiPassword: '',
208+
wolTargetMac: '',
203209
sleepKeybindEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.sleepKeybindEnabled,
204210
speakerVolumeShortcutEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.speakerVolumeShortcutEnabled,
205211
pollingRateMode: DEFAULT_CONTROLLER_PROFILE_SETTINGS.pollingRateMode,
@@ -1039,6 +1045,18 @@ function normalizeSettings(value: Partial<CompanionSettings> | null | undefined)
10391045
wakeOnConnectEnabled: typeof value?.wakeOnConnectEnabled === 'boolean'
10401046
? value.wakeOnConnectEnabled
10411047
: DEFAULT_SETTINGS.wakeOnConnectEnabled,
1048+
wolEnabled: typeof value?.wolEnabled === 'boolean'
1049+
? value.wolEnabled
1050+
: DEFAULT_SETTINGS.wolEnabled,
1051+
wolWifiSsid: typeof value?.wolWifiSsid === 'string'
1052+
? value.wolWifiSsid.slice(0, WOL_WIFI_SSID_MAX_LENGTH)
1053+
: DEFAULT_SETTINGS.wolWifiSsid,
1054+
wolWifiPassword: typeof value?.wolWifiPassword === 'string'
1055+
? value.wolWifiPassword.slice(0, WOL_WIFI_PASSWORD_MAX_LENGTH)
1056+
: DEFAULT_SETTINGS.wolWifiPassword,
1057+
wolTargetMac: typeof value?.wolTargetMac === 'string'
1058+
? value.wolTargetMac.trim()
1059+
: DEFAULT_SETTINGS.wolTargetMac,
10421060
sleepKeybindEnabled: typeof value?.sleepKeybindEnabled === 'boolean'
10431061
? value.sleepKeybindEnabled
10441062
: DEFAULT_SETTINGS.sleepKeybindEnabled,

companion/src/preload.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,18 @@ const api = {
140140
setWakeOnConnectEnabled: (value: boolean): Promise<BridgeSnapshot> => (
141141
ipcRenderer.invoke('bridge:setWakeOnConnectEnabled', value)
142142
),
143+
setWolEnabled: (value: boolean): Promise<BridgeSnapshot> => (
144+
ipcRenderer.invoke('bridge:setWolEnabled', value)
145+
),
146+
setWolWifiSsid: (value: string): Promise<BridgeSnapshot> => (
147+
ipcRenderer.invoke('bridge:setWolWifiSsid', value)
148+
),
149+
setWolWifiPassword: (value: string): Promise<BridgeSnapshot> => (
150+
ipcRenderer.invoke('bridge:setWolWifiPassword', value)
151+
),
152+
setWolTargetMac: (value: string): Promise<BridgeSnapshot> => (
153+
ipcRenderer.invoke('bridge:setWolTargetMac', value)
154+
),
143155
setSleepKeybindEnabled: (value: boolean): Promise<BridgeSnapshot> => (
144156
ipcRenderer.invoke('bridge:setSleepKeybindEnabled', value)
145157
),

0 commit comments

Comments
 (0)