Skip to content

About

Wi-Fi, Bluetooth, audio and panel brightness control for .NET on Windows - the parts that are undocumented or unavailable to unpackaged apps. Unelevated where Windows allows it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

WindowsDeviceControl

Wi-Fi, Bluetooth, audio, display brightness, display layouts, power and storage control for .NET on Windows. The parts that are awkward or undocumented, in one library, callable from an ordinary unpackaged process.

dotnet add package WindowsDeviceControl

Targets net8.0-windows10.0.19041.0 and net10.0-windows10.0.19041.0. No COM registration, no packaged identity, no separately shipped native component, and no admin rights except where Windows itself demands them. The library was extracted from a Windows shell application. Hardware support and policy access still depend on the Windows build, driver, endpoint and calling process; dated observations below describe only the scenarios that were exercised.

Documentation

Why this exists

Each of these is solvable on its own, and collectively they are a bad week.

Wi-Fi from an unpackaged process. WinRT's WiFiAdapter needs the wiFiControl capability, which an unpackaged desktop, service or kiosk application cannot declare. That leaves the native WLAN API and hand-written profile XML, including the part nobody enjoys: a saved profile has to exist before you can join a protected network, and the SSID may not be valid UTF-8.

Default audio endpoint switching. There is no public API. It goes through IPolicyConfig, a COM interface Microsoft never documented and whose vtable ordering differs across Windows versions.

Spatial sound and the endpoint format. Windows Sonic, Dolby Atmos and DTS have a public WinRT API, but it only answers when handed the WinRT device id built from the endpoint id, and it reports an unknown device as "no spatial sound" rather than failing. The default format behind the Advanced tab and the speaker-setup wizard, which is where channel count and 5.1 or 7.1 layout live, has no public API at all and goes through the same IPolicyConfig.

Panel brightness. The documented route is WMI WmiMonitorBrightnessMethods, which requires elevation and silently does nothing on many laptop and handheld panels. The ACPI backlight device answers the same request unelevated.

Bluetooth pairing that shows a PIN. Enumerating and connecting is easy. Running the pairing ceremony, accepting a deferral, surfacing the PIN to your own UI and answering it, is where the examples run out.

Usage

using WindowsDeviceControl;

// Wi-Fi
var status   = WindowsRadio.GetWifiStatus();
var networks = WindowsRadio.ListWifiNetworks();
WindowsRadio.RequestWifiScan();
var network  = networks[0].Key;                                       // SSID bytes + security
var joined   = WindowsRadio.ConnectWifi(network, "passphrase");      // Joined, Failed, Pending, Refused
if (joined.Outcome == WindowsRadio.WifiConnectOutcome.Failed)
{
    Console.WriteLine(WindowsRadio.ReasonText(joined.ReasonCode));    // Windows' own wording
    // ...and only re-prompt when the key was actually the problem.
    if (WindowsRadio.GetReasonVerdict(joined.ReasonCode) == WindowsRadio.WifiFailureKind.KeyRejected)
        AskForPassphraseAgain();
}
WindowsRadio.ForgetWifi(network);                                    // every matching profile

// Change feeds: one disposable registration per call; disposing it is the stop
using var wifiWatch = WindowsRadio.StartWifiWatch(change => Post(change));

// Bluetooth, including the pairing ceremony
foreach (var d in WindowsRadio.ListBluetoothDevices(pairedOnly: false))
    Console.WriteLine($"{d.Name} paired={d.Paired} connected={d.Connected}");

var paired = await WindowsRadio.PairBluetoothAsync(deviceId, onRequest: request =>
{
    // Show request.Pin in your own UI, then answer before the attempt's deadline.
    WindowsRadio.RespondToPairing(request.Token, accept: true, pin: null);
}, cancellationToken);                                                // TimeoutException after 90 s

// Radio power (airplane-mode aware), with each adapter's answer
WindowsRadio.GetPower(WindowsRadio.RadioKind.Bluetooth);
var power = WindowsRadio.SetPower(WindowsRadio.RadioKind.WiFi, on: true); // power.Access, power.Adapters

// Audio
CoreAudio.ListEndpoints(CoreAudio.AudioDirection.Render, out var outputs);
CoreAudio.SetDefaultEndpoint(outputs[0].Id, out var roleResults); // transactional per-role results
CoreAudio.SetVolume(35, out _);
CoreAudio.SetMuted(true);

// Spatial sound and the endpoint format, both applied to the running engine at once
CoreAudio.GetSpatialAudio(outputs[0].Id, out var spatial);            // supported formats, default, active
CoreAudio.SetSpatialAudio(outputs[0].Id, CoreAudio.SpatialAudioFormats.WindowsSonic, out var spatialStatus);
CoreAudio.ListSupportedDeviceFormats(outputs[0].Id, out var formats); // what the Advanced tab would offer
CoreAudio.SetDeviceFormat(outputs[0].Id, CoreAudio.AudioDeviceFormat.Pcm(channels: 6, sampleRate: 48000, bitsPerSample: 24));

// Brightness, no elevation
if (Backlight.TryReadBrightness(out int percent))
    Backlight.TrySetBrightness(Math.Min(100, percent + 10));

// Active CCD topology. Persist the whole Target, including EdidIdentity, then discard saved
// adapter/target coordinates after every topology change.
DisplayTopologySnapshot topology = DisplayTopology.CaptureActive();
DisplayTargetIdentity television = topology.Paths[0].Target;
DisplayScaleResult scaled = DisplayScaling.Set(television, 150); // Written, AlreadySet, Refused, ...
Type Role
WindowsRadio Radio power, Wi-Fi scan, list, connect and forget, Bluetooth discovery and pairing, change watches (StartWifiWatch, StartBluetoothWatch)
WifiProfile Builds profile XML: CreateOpen, CreatePsk in WPA3-transition, WPA2-AES and WPA-TKIP shapes; survives a non-UTF-8 SSID
CoreAudio Endpoints, default-endpoint switching, volume and mute per direction, change watches (StartVolumeWatch, StartEndpointWatch), spatial sound, default format and channel layout, Bluetooth audio connect and disconnect
AudioFilePreview Plays one local audio file on the default route and reports a failure as AudioPreviewFailure
WaveOutFeedback The short click Windows itself plays for volume feedback
Backlight Internal panel brightness over the ACPI backlight device
DisplayTopology Active CCD paths and rematchable monitor identities (DisplayTargetIdentity, ActiveDisplayPath, DisplayTopologySnapshot)
DisplayLayouts Complete desktop arrangements by value: which monitors are on, which is primary, position, mode, scaling and HDR
DisplayModes Driver-validated modes of one active display, applied once without persisting, plus the primary display's transient mode
DisplayEdid Progressive timings read from one monitor's EDID, even while its source is disabled
DisplayScaling One display's Windows scaling percentage, read and written through the relative-step packets
DisplayColor One display's advanced colour (HDR) state, with support read before every write
WindowsPower Schemes, AC and DC values, effective mode overlays, status, suspend and shutdown, notifications, hybrid core placement
PowerNotificationRegistration A power-setting or suspend and resume notification registration; disposing it unregisters
WindowsPowerRequest A display or system wake request and its reason string
PowerRequestList The system-wide power request list that powercfg /requests shows, decoded with bounds checks
WindowsWakeSecurity Wake-policy recovery values, capture and restore
ModernStandby S0 idle support, wake devices, resume attribution, standby timing
WindowsStorage Mounted volumes and the physical disk number behind each one, read-only

The other public types are the records, results and enums these members take and return, documented beside them.

Every public member is documented and the build fails on one that is not, so IntelliSense is the reference for callback threads, blocking, consent, error meanings and ownership.

Threading

Synchronous Windows calls block the calling thread, including radio power, Bluetooth listing and unpairing, spatial sound, display driver probes and the Wi-Fi connection wait. Run these operations on a worker. Pure value helpers do not call Windows. PairBluetoothAsync, WindowsPower.SuspendAsync and WindowsPower.RequestActionAsync return tasks and take cancellation tokens, with different cancellation boundaries: pairing ends its own attempt, suspend cancels only before dispatch, and session actions can cancel admission or waiting but cannot undo dispatch.

AudioFilePreview and WaveOutFeedback require serialized owner calls. WindowsPowerRequest is thread-safe. Display writes share a process-local gate; that gate cannot serialize Settings, drivers or another application. The library does not make a sequence of caller operations atomic.

Watch callbacks, pairing questions and AudioFilePreview.Failed arrive on a Windows thread, never the caller's, so marshal them to your UI yourself. A watch delivers one callback at a time per registration and contains a callback exception. A pairing callback that throws declines its question and ends the attempt with that exception.

Native codes are kept on purpose, because renaming them would hide what they are. A failed ConnectWifi carries Windows' raw WLAN reason code, which you pass to ReasonText or GetReasonVerdict; a failed WLAN list or scan is a Win32Exception whose NativeErrorCode is the WLAN status (5 is the Windows 11 24H2 location gate); and the CoreAudio methods returning int return HRESULTs. Everything else is a named enum: radio kind, audio direction, network security, connection state and outcome, pairing kind and outcome, watch events, volume-key commands and Wi-Fi failure classification.

A Wi-Fi network is identified by its WifiNetworkKey, the SSID's exact bytes plus its security class, never by display text: two names can decode to the same text, and the same name can be advertised with different security. Every watch returns a registration of its own; several can run side by side, and disposing one is that registration's stop.

What Windows will still refuse you

Written down here rather than discovered at deployment time.

Radio power needs consent. RequestAccess() returns DeniedByUser when the user has denied radio control to your application, and DeniedBySystem on a policy-managed or kiosk-provisioned machine. Neither is retryable, so check before offering a toggle.

Wi-Fi enumeration can require location consent. GetConsent(capability) reports what the privacy store records, as a diagnostic only, since the owning API remains the authority on what is permitted. This ambushes kiosk deployments in particular, because the machine is often provisioned with location off.

Not every panel has an ACPI backlight. TryReadBrightness returns false when the device cannot be opened, its query fails or its reply is invalid. This means brightness is unavailable to this call, which is normal on a desktop; it does not prove that the machine has no panel. A failed write likewise returns false, with no retry or readback.

docs/radios.md records the platform constraints behind all of this, including the approaches that were tried and did not work. Read it before changing how a Windows API is called.

Audio file preview

AudioFilePreview owns local audio preview through Windows Media Foundation and the default audio route. Play takes an existing absolute path; Stop releases playback and Dispose ends the owner. It changes neither endpoint selection nor system volume. Unsupported or corrupt media raises Failed on the media callback thread with an AudioPreviewFailure: Windows' error class, the extended HRESULT and its message. Consumers serialize owner calls and marshal failures to their UI. A stopped preview's queued failure cannot replace a newer preview's status.

Displays

Display identity prefers the EDID manufacturer/product and serial stored in EdidIdentity, so a driver update can change the device-interface path without losing the monitor. When either serial identity is unavailable, matching uses the device path; manufacturer/product IDs are a fallback only when neither observation has a path. Identical or missing serials can leave a match ambiguous; the library refuses ambiguous routes instead of choosing one. Friendly names and DISPLAY1 numbering are presentation data. Adapter LUID and target ID describe the current route and have to be refreshed after hotplug. Enumeration retries the documented sizing race, sizes its buffers from what Windows reports and stays read-only. Possible source and target combinations can be far larger than the active display count: a desktop query on 2026-09-13 returned 284 possible routes and three active ones.

The display records are plain positional data a caller can persist. Members are only ever added, each with a default, so an older stored value still reads.

One display whose name cannot be read (a monitor dropping out, an indirect or virtual display) does not hide the others: CaptureActive leaves it out, and a lookup by identity reports that display as unreadable rather than inactive only when the unreadable path sits on its last known route.

The library has no wait of its own. A caller waiting for a monitor to arrive repeats DisplayLayouts.Observe() and acts once two fingerprints a moment apart agree, on whatever timing and cancellation it needs.

Every display write in the process (layout apply, mode apply, the transient primary mode, scaling and advanced colour) takes one shared gate, so two writers never interleave; reads take no lock. A write Windows accepts is the result and is not read back. Results carry outcome codes and native statuses, not text: the caller words them.

Editable display layouts

DisplayLayouts holds a desktop arrangement as editable values.

Observe() reports readable monitor identities, active or not. Its fingerprint covers identity, availability, active state, position, resolution and refresh; it excludes rotation, scaling and HDR. Two equal fingerprints a moment apart help a caller wait for an arrival to settle but do not prove all properties are unchanged. Capture() returns the current desktop as values: identity, position, resolution, refresh, rotation, scaling and HDR. Validate(layout) asks Windows without changing anything, and Apply(layout) applies once. A layout output's rotation of zero keeps whatever rotation the display runs at; any other value is written and compared. An apply and its rollback are saved to the Windows display database, so the arrangement survives a reboot; a caller that needs to undo it applies the layout it captured before.

Describe(layout) is the rule set on its own, returning the DisplayLayoutProblem the layout breaks or null. It is pure and touches no display, so an editor can refuse a layout as it is typed and a stored layout can be checked while the monitors it names are unplugged.

A layout is checked before Windows sees it: at least one display, exactly one at 0,0 as the primary, no duplicates, no overlaps, and every display touching the arrangement, because Windows snaps a detached desktop to something other than what was asked for. A requested monitor that is not connected returns TargetsAbsent rather than throwing, so a caller can wait for a television that only appears once an HDMI switch selects this machine. An arrangement that already matches returns AlreadyActive without rewriting topology. Requested scaling and HDR are still applied separately, and their refusals still appear in Warnings.

The planner supplies a complete configuration: the path already driving a monitor keeps its source, a second display never shares one, monitors left out of the layout are supplied inactive, and the target mode index is left invalid so the driver picks a signal for the requested resolution and rate. Scaling and HDR are applied per display afterwards, and a refusal there is a typed warning rather than a reason to undo an arrangement that is already on screen. An application Windows refuses gets exactly one topology rollback attempt. When that is accepted, captured scaling and colour are restored best effort. RollbackSucceeded describes the topology write status only; rollback-extra failures are not returned in Warnings. Nothing is retried automatically. SDC_TOPOLOGY_SUPPLIED is deliberately not used, because it takes modes from the Windows database and so cannot express position or resolution.

Planned source modes use DISPLAYCONFIG_PIXELFORMAT_32BPP (4), and requested refresh rates use progressive scan ordering. On 2026-09-13, a connected but inactive HISENSE on Windows build 26200 rejected a 3840x2160 at 60 Hz layout with native error 87 while scan ordering was unspecified. Setting progressive ordering made native validation succeed without changing the requested rate. That is validation evidence and does not by itself prove a completed display switch.

Reference comparisons used DisplayMagician's CCD implementation and ColorControl's CCD implementation.

Supported display modes

DisplayModes.Read(target) returns fresh current and driver-validated modes for an active CCD target. DisplayModes.Apply(snapshot, mode) rechecks the exact route and advertised mode and applies once without persisting registry settings. A write Windows accepts is Applied; a refused one gets one write-back of the captured mode while the route is unchanged. Run these blocking driver operations on a worker thread. Clone sources are refused, because changing one source affects several targets. Driver acceptance does not prove that a picture is visible.

Native API reference: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-changedisplaysettingsexw

DisplayEdid.ReadModes(target) reads recognized progressive timings from the exact monitor's EDID, including when Windows has disabled its source, with a status that separates a missing monitor, a timed-out lookup and a missing descriptor. Call it on a worker. It uses DisplayMonitor.FromInterfaceIdAsync and GetDescriptor with a three-second interface lookup budget, and no display is activated or tested against the driver. These are candidates for a saved layout; the apply path still has to validate the complete arrangement.

The reader checks block checksums and bounds and handles base established, standard and detailed timings, common CTA progressive video codes and detailed timings, and DisplayID type I and VII detailed timings. Unknown timing formats and interlaced entries are skipped. Integer refresh rates follow DisplayMode, and EDID does not supply the driver's complete set of scaled or custom modes.

Timing format references: DRM EDID timing definitions and libdisplay-info DisplayID decoding.

On 2026-09-13, read-only checks on Windows build 26200 returned EDID modes for three connected but disabled monitors: Odyssey G7 (3840x2160 at 165 Hz), HP X32 (2560x1440 at 165 Hz) and Odyssey G93SC (5120x1440 at 240 Hz). That did not apply those modes or prove they are visible.

Windows power policy

WindowsPower exposes scheme enumeration and localized names, active scheme read and write, AC and DC policy values, and effective power-mode overlays. These synchronous calls belong off UI threads. Native failures throw Win32Exception with the Windows error code. Each request is issued once, and a read reports what Windows stores. The order of several writes is the caller's.

Guid scheme = WindowsPower.GetActiveScheme();
string name = WindowsPower.ReadSchemeName(scheme);
Guid mode = WindowsPower.GetEffectiveMode();
// Explicit user action:
WindowsPower.SetActiveScheme(scheme);

WriteSetting updates a scheme's stored AC or DC value without activating it. Call SetActiveScheme when your policy requires immediate application. A successful request is no guarantee that another Windows policy writer will leave the value alone.

SuspendAsync requests standby or hibernation off-thread and usually completes after resume. RequestActionAsync supports shutdown, restart and sign-out using the absolute system tool path, and completion means the tool accepted the request. Cancellation cannot undo a dispatched operation. TryGetStatus reports source and battery values with Windows' unknown sentinels intact. Window owners can register power-setting notifications and suspend and resume notifications. Each returns a PowerNotificationRegistration that unregisters when disposed, and a refused registration throws Win32Exception with the native error. The suspend and resume registration is what delivers PBT_APMSUSPEND and the resume codes to a message-only window, which the broadcast alone never reaches.

WindowsPowerRequest owns a display or system wake request and its reason string. Acquire and Release are idempotent, failures throw with native error codes, and a failed release stays held until an explicit retry or disposal. Disposal closes the kernel handle without repeating a failed clear. PowerRequestList.Query returns the entries with a PowerRequestListStatus: anything other than Read comes with null entries, AccessDenied when the read needs elevation, QueryFailed with the NTSTATUS, UnrecognizedLayout when the undocumented layout cannot be decoded safely, and Unsupported in a 32-bit process. The buffer grows until the whole list fits; nothing is truncated.

WindowsWakeSecurity.Capture returns wake-policy recovery values, absent values included, and refuses a value that is not a DWORD because it could not be restored exactly. Persist that snapshot before writing with SetConsoleLockPolicy, SetSchemeConsoleLock (each scheme from WindowsPower.EnumerateSchemes, then WindowsPower.RefreshActiveScheme) and SetNoLockScreen, where -1 deletes a value. Restore writes only what the snapshot recorded, skips a scheme removed since, attempts every step once and returns the ones that failed; keep the snapshot until it reports none. Registry writes need elevation, and this library never elevates and never stores application configuration. Some Windows editions ignore personalization policy even after accepting its registry value.

Modern Standby wake sources

ModernStandby.Query reports whether the machine supports S0 low-power idle and in what connected form, and which mandatory wake paths exist: power button, sleep button, lid, wake alarm. Those are reported and never written. Nothing in this class can take away a recovery wake path.

EnumerateWakeDevices returns the wake sources a caller can act on: the devices Windows reports as programmable, plus any that are armed without being programmable. The second group comes back as WakeDeviceControl.Fixed, visible and reportable but refused for writes. Devices that merely support waking from S0 are deliberately excluded, since on a handheld that is most of the HID and Bluetooth endpoints, none of which Windows offers for change.

WakeDeviceSnapshot snapshot = ModernStandby.CaptureWakeDevices();
foreach (WakeDevice device in ModernStandby.EnumerateWakeDevices())
{
    if (device is { Control: WakeDeviceControl.Programmable, Armed: true })
    {
        ModernStandby.TrySetWakeArmed(device.Name, armed: false);
    }
}
IReadOnlyList<WakeDeviceRestoreFailure> failures = ModernStandby.RestoreWakeDevices(snapshot);

TrySetWakeArmed re-reads programmability rather than trusting the caller's record, returns false for a device Windows no longer offers, and is idempotent. Writes need elevation: unelevated, Windows fails the set with ERROR_WMI_SET_FAILURE (4214), which surfaces as a Win32Exception. RestoreWakeDevices touches only devices the snapshot observed, because a device that appeared since has no prior state to restore. It enumerates once, attempts every write even after one fails, and returns the devices whose write failed with their native error; keep the snapshot until that list is empty.

WasLastResumeUnattended reports whether Windows attributes the last resume to something other than the user, meaning a wake timer, a device or background work rather than a button, key or lid. It is the one call that separates a wake worth staying awake for from one worth suspending again on, so read it on the resume notification rather than caching it.

ReadStandbyTiming reads the last sleep and wake interrupt-time marks and then the current one on the same clock, giving Slept and SinceWake for a grace period or a standby diagnostic. Those sequential reads are not an atomic snapshot.

Neither of those reports what woke the machine. Windows exposes no documented call for that.

Software wake sources are ordinary power settings. ModernStandby exposes their identities, SettingAllowWakeTimers, SettingAllowAwayMode, SettingUnattendedSleepTimeout, SettingConnectivityInStandby, SettingDisconnectedStandby and the two subgroups, and they are read and written with WindowsPower.ReadSetting and WriteSetting plus RefreshActiveScheme. The library stores no policy of its own for them.

Hybrid processor core placement

QueryHybridCores reports the machine's processor efficiency classes from Windows CPU set information, and whether a scheme exposes the three hidden processor settings that steer thread placement on hybrid parts: HETEROPOLICY, SCHEDPOLICY and SHORTSCHEDPOLICY. Offer these controls only when the machine is hybrid and the scheme is configurable. The published value lists come from Windows, so a build that publishes none returns empty lists rather than an invented range.

Guid scheme = WindowsPower.GetActiveScheme();
HybridCoreSupport support = WindowsPower.QueryHybridCores(scheme);
if (support is { Hybrid: true, Configurable: true })
{
    HybridCoreState previous = WindowsPower.ReadHybridCores(scheme, onBattery: false);
    WindowsPower.WriteHybridCores(scheme, onBattery: false,
        previous with { Threads = HybridSchedulingPolicy.PreferPerformantProcessors });
    WindowsPower.RefreshActiveScheme();
}

AC and battery values are separate, so read and write each source explicitly. ReadHybridCores is the snapshot to persist before a write and to restore from afterwards, because WriteHybridCores issues its three writes in order and does not roll back. Windows applies processor policy when a scheme is activated, so a write to the active scheme needs RefreshActiveScheme to take effect. A policy value this library does not name is preserved as its raw number rather than replaced.

Storage

WindowsStorage.DescribeVolumes() enumerates local drive-letter volumes and reports capacity, available free space, readiness and the disk number returned by IOCTL_STORAGE_GET_DEVICE_NUMBER. Network drives are excluded. An unreadable drive's metadata can omit that row, while a failed disk lookup retains the row with DiskNumber = -1. Never join unknown disk numbers as one physical disk. The string overload of DiskNumberFor uses the first character as a drive letter; it does not resolve folder mount points, volume GUID paths or UNC paths. All storage operations are read-only.

Building and testing

WindowsDeviceControl.slnx contains the library and its hardware-independent tests. From this repository's root, build both supported frameworks; after the applicable manual validation, run each test target explicitly:

dotnet build WindowsDeviceControl.slnx -c Release
dotnet test WindowsDeviceControl.slnx -c Release --no-build -f net8.0-windows10.0.19041.0
dotnet test WindowsDeviceControl.slnx -c Release --no-build -f net10.0-windows10.0.19041.0

The .NET SDK must support the .slnx format and both target frameworks. Cross-compilation uses EnableWindowsTargeting from Directory.Build.props; it does not make native calls executable on Linux or macOS. XML comments ship with the assembly, and missing public documentation or partial parameter documentation fails the library build. Hardware-independent tests cover decision logic and native layouts, not live driver behavior. See the source reference for the test map and contributor guide for validation requirements.

Status

Pre-1.0. The surface can still move before it is frozen, so pin an exact version if that matters to you.

Issues and pull requests are welcome, especially hardware reports. The behaviour of Wi-Fi drivers, Bluetooth stacks and backlight interfaces varies more across machines than any of the underlying documentation admits.

Licence

MIT, see LICENSE.

About

Wi-Fi, Bluetooth, audio and panel brightness control for .NET on Windows - the parts that are undocumented or unavailable to unpackaged apps. Unelevated where Windows allows it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages