v4.2.x (Beta): Yeelight + Alienware + Dynamic Lighting + Copy Layers + QMK + Logitech fixes#193
Open
logicallysynced wants to merge 30 commits into
Open
v4.2.x (Beta): Yeelight + Alienware + Dynamic Lighting + Copy Layers + QMK + Logitech fixes#193logicallysynced wants to merge 30 commits into
logicallysynced wants to merge 30 commits into
Conversation
Two Dependabot updates rolled into a single bump: - PR #190: Avalonia + Avalonia.Desktop + Avalonia.Themes.Fluent + Avalonia.Fonts.Inter + Avalonia.Controls.ColorPicker 12.0.2 → 12.0.3. Applied to both Chromatics and Chromatics.DecoratorHarnessUI for parity. - PR #192: System.Drawing.Common 10.0.7 → 10.0.8. Both PRs will auto-close when Dependabot reconciles against the new manifest. Build clean, 130/130 tests pass on both packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New custom RGB.NET extension for QMK firmware keyboards over Raw HID.
Brand-agnostic — discovery filters on HID usage page 0xFF60 / usage 0x61
so it works on any QMK board (NovelKeys, KBDFans, Drop, GMMK, Glorious
and others) without a hardcoded VID/PID allow-list.
Architecture:
- Two protocols handled on the same HID interface. Handshake decides
which the device speaks:
- VIA-only (universal): single-LED device, drives RGB matrix base
hue/sat/val + effect mode.
- OpenRGB-QMK (firmware-side plugin): full per-key control via
direct mode, chunked SetLedRange writes with 1ms inter-packet
pacing.
- Per-key boards get a semantic LedId.Keyboard_* mapping by looking
up the VIA keymap JSON for their VID/PID from
www.caniusevia.com on first connect and merging against the
firmware's GetLedInfo matrix coordinates. Cached on disk under
%APPDATA%/Chromatics/QmkKeymaps. Falls back to LedId.Custom1..N
with a synthetic grid when no keymap is fetchable (offline /
unknown board) so the device still works via the Mapping tab
drag-position UX.
- Auto-adopt on first enable: discovery runs, every responding board
is registered to SettingsModel.deviceQmkRawHidAdoptedDevices and
picked up by the provider's adopted-set filter. Subsequent
launches reuse the persisted list. Per-keyboard disable on the
Mapping tab covers the "I don't want this one" case for v1 Beta.
- Hot-plug parity with PlayStation provider: DeviceList.Local.Changed
reconciles the open set on USB connect/disconnect events. Per-
device disable gate parity with LIFX / Hue queues so the Mapping
tab disable persistence works end-to-end.
UX:
- Settings → Device Providers gets a "QMK Keyboards (Beta)" toggle
with brand-list tooltip.
- First-run device selector adds a matching tile.
- Locale strings added to en.json and translated into the six
non-EN locales via translate.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s index (v4.1.41) The original fetcher pointed at www.caniusevia.com URLs that don't exist and assumed a JSON schema (keycap labels embedded as "row,col\n\n\nA" strings) that via-keyboards doesn't actually use. Rewriting against QMK's keyboard.json instead: - Source: snakkarike/qmk_firmware master branch (covers all the NovelKeys boards plus 2500+ other QMK keyboards). NovelKeys boards land under keyboards/novelkeys/ with proper VID/PID and rgb_matrix layouts. - Index: 2650 unique VID/PID entries built once by scripts/build_qmk_keymap_index.py and hosted at raw.githubusercontent.com/logicallysynced/chromatics-docs/main/qmk_keymap_index.json. - Schema: QMK keyboard.json's layouts.<first>.layout array. Each entry has matrix=[row,col] and (often, not always) a label="Esc"/"F1"/etc. Label coverage is inconsistent across boards — NK87 has it, NK65 doesn't — so the merge step gracefully falls back to LedId.Custom_* for LEDs whose matrix position has no label in the JSON. Partial semantic mapping is still better than the alternative of no mapping at all. - Cache: moved to FileOperationsHelper.GetConfigDirectory()/QmkKeymaps so it lives alongside the rest of the user's Chromatics state under %AppData%/Chromatics. SettingsViewModel.ResetChromatics now wipes this cache and the in-memory index when the user hits Reset. QmkKeycodeMap expanded to accept both QMK keycode shortforms ("ESC", "BSPC"), the long KC_ prefix form ("KC_ESC"), and the human legend form QMK keyboard.json's label field actually uses ("Esc", "Backspace", "Page Up"). All three resolve to the same LedId.Keyboard_*. Caveats: - Label coverage is partial — boards without labels in their keyboard.json land in Custom_* and the user maps via Mapping tab. - The OpenRGB-QMK command IDs (0x20-0x2A) in OpenRgbQmkProtocol.cs still need verification against the upstream OpenRGB master firmware fork. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 stale files under Chromatics/obj/ have been tracked since the .NET 5 / .NET 6 era. They're regenerated on every build, contain machine-absolute paths inside Chromatics.csproj.nuget.dgspec.json (useless on a fresh clone), and are already covered by .gitignore's */obj entry — the only reason they kept showing up in commits is that they were committed before the gitignore landed and git keeps tracking files it has already seen. git rm --cached -r — files stay on disk locally, just stop being tracked. The current net10 build writes to obj/Debug/net10.0-windows7.0/ which the .gitignore correctly excludes; the leftover net5.0-windows / net6.0-windows / Release intermediates here aren't referenced by anything the current solution builds. No code-behaviour impact, just diff hygiene. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1.42) Replaces the previous network fetch (chromatics-docs CDN + per-board keyboard.json from snakkarike/qmk_firmware) with a single embedded resource. The bundled data covers 2650 boards from snakkarike's qmk_firmware fork; ~500 of them have non-empty keycap labels (the boards whose source keyboard.json declared "label" fields), so semantic LedId.Keyboard_* mapping works for that subset. The remaining boards are present in the bundle but fall back to LedId.Custom_* at the merge step. Side effects: - QmkKeymapFetcher rewritten — synchronous bundle load on first call, no HTTP, no disk cache, no failure mode for offline users. - SettingsViewModel.ResetChromatics's ClearCache hook is removed since there's no cache to clear. - Bundle size: 444 KB compact JSON, ~one-time read at provider load. Refresh path: re-run build_qmk_keymap_index.py from the workspace root when new boards land upstream and commit the resulting JSON. Also picks up the README.md edits the user had in flight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously: enabling the QMK Keyboards toggle with no boards plugged in flashed the button on and then back off with no console output, no dialog — looking exactly like a bug. Now: - QmkRawHidDiscovery logs the full enumeration breakdown to the console (HID device count, Raw HID candidates found, open-failures, handshake misses, final usable count). When a candidate fails handshake the log includes the reason — most usefully, "could not open the Raw HID interface (likely held exclusively by another app — close VIA / Vial / OpenRGB and try again)". - SettingsViewModel's enable handler now logs "Scanning..." before discovery, and pops a localised "No QMK Keyboards Found" dialog when discovery returns empty so the user can see exactly why the toggle didn't take. Strings routed through LocalizationService.Instance per the localization rule; en.json + the six non-EN locales updated via translate.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trims the 75-word "No QMK Keyboards Found" body string to 55 words.
Removes the em dash, rewrites the trailing passive ("HID devices were
enumerated") to active voice, and drops the redundant "on this PC"
opener. Same information, less wall-of-text.
en.json key updated, SettingsViewModel reference updated to match, and
translate.py rerun to refresh the six non-EN locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same scope, professional register, with three fixes: - Em dash replaced with normal phrasing. - Passive voice "is driven via the OpenRGB-QMK plugin" rewritten to active voice with an explicit subject. - Marketing phrase "out of the box" replaced with "without manual setup". Stale "via-keyboards database" wording corrected to reflect the v4.1.42 bundling work (data ships embedded; no runtime fetch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(v4.1.45) Background: Logitech per-key keyboards arrive from RGB.NET with every LED at Y=0 (LogitechPerKeyRGBDevice.InitializeLayout flat-rows them at pos*19,0). Conical gradients then collapse via atan2(0, dx) to a left half / right half fade. The historical fix was to overlay a per-board XML from RGB.NET-Resources, but that repo is archived and isn't being maintained for new boards. LogitechLayoutFixup now derives Led.Location from KeyLocalization's QWERTY grid (cell size 19, mirroring RGB.NET's own value) so any Logitech keyboard with standard Keyboard_* LedIds gets a topologically correct layout. No data files, no upstream dependency. DefaultLayoutInference replaces the two 0-LED XML fallbacks in RGBController. The keyboard path used to load Artemis-XL-ISO for every 0-LED keyboard regardless of make; the headset path was broken entirely (pointed at /Keyboard/Artemis 4 LEDs headset.xml when the file lives under /Headset/, so File.Exists silently failed on every run). Keyboards now get a full QWERTY ANSI grid via AddLed; headsets get a 2x2 left-ear / right-ear quartet. Drops the RGB.NET.Layout NuGet package (no longer referenced) and the Release Build/Layouts directory (publish.py used to copy it verbatim into the installer; it's gone now). Other Logitech-investigation fixes that rode along: - WarnLimitedDevices: provider-load hint when Logitech hands us a LogitechZoneRGBDevice or LogitechPerDeviceRGBDevice. Radial / per-key effects degrade on those classes (zone-stripe or single-colour paint), and without this the user couldn't tell whether it was a hardware limit or a Chromatics bug. - Decorator harness DLL path resolution: RGB.NET's PossibleX64NativePaths is relative to cwd, not the executing assembly. Harness now resolves paths via Assembly.GetExecutingAssembly().Location so Logitech and Corsair detect with the same x64/ DLLs the main app uses. - Harness MSBuild target: mirror x64/x86 native DLLs from main Chromatics's bin so the harness has the same providers available after a clean rebuild without manual file shuffling. - Harness Dispose fix: each provider's UpdateTrigger spawns a foreground LongRunning thread; surface.Dispose only stops triggers registered through RegisterUpdateTrigger. Disposing the loaded provider lets those threads exit so the process terminates on window close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New RGB.NET provider for Yeelight bulbs, light strips, lamps, and ceiling lights over the standard Yeelight LAN protocol. No cloud account, no third-party DLLs, no proxy software needed. Discovery runs on first enable: SSDP M-SEARCH against 239.255.255.250:1982, adopts every bulb that responds. Subsequent launches reuse the persisted adoption list and re-resolve only the IPs that may have changed. Users disable specific bulbs they don't want Chromatics to drive from the Mapping tab. Architecture mirrors the LIFX provider: - YeelightDiscovery: SSDP multicast send + parse of yeelight:// Location lines into a strongly-typed DiscoveredBulb. Tolerant of header order and case across firmware versions. - YeelightConnection: persistent TCP per bulb (port 55443), JSON- over-TCP commands for set_rgb / set_bright / set_power / set_ct_abx, plus the bg_set_* variants for the secondary light element on dual-light bulbs. - YeelightUpdateQueue: 30Hz with per-channel diff cache (skip identical frames per main + bg independently), LIFX-style per-device-disable gate, 60s keep-alive to fend off the bulb's auto-power-off in Music Mode. - YeelightDeviceUpdateTrigger: 50ms idle wake so per-device brightness slider changes propagate when bulbs are sitting on a static layer. - YeelightModelCatalog: maps the SSDP `model` field to a friendly display name and an RGBDeviceType (LedStripe for strips, LedMatrix for cube, LedController for everything else, matching LIFX's precedent for non-peripheral lights). Music Mode handshake reverses the connection direction (the bulb dials back to a TCP listener Chromatics opens) and removes the LAN protocol's normal cap of 60 commands per minute. Falls back gracefully to the outbound channel when reverse TCP can't be established (firewall, NAT). Capability gated on the SSDP `support` list so older bulbs without set_music aren't probed. Dual-light bulbs (Bedside Lamp 2, certain ceiling lights) expose two LEDs (Custom1 = main, Custom2 = background) with the channel role stamped into Led.LayoutMetadata. The UpdateQueue reads each LED's channel role to dispatch to set_rgb vs bg_set_rgb, so mapping each LED independently in the Mapping tab routes paint to the right firmware element. Light strips paint as one colour because the public Yeelight LAN spec doesn't expose per-zone addressing on strip products, even on hardware that physically supports it (Lightstrip Plus). This is a firmware limitation and is documented in the changelog so users don't expect LIFX Z-style behaviour. Wiring: - SettingsModel.deviceYeelightEnabled + deviceYeelightAdoptedDevices. - SettingsViewModel device-toggle row with the auto-adopt path (mirrors QMK), localised "no bulbs found" dialog explaining the LAN Control prerequisite and IoT VLAN gotcha. - RGBController.Setup hydrates ClientDefinitions from settings, runs initial auto-adopt sweep when the persisted list is empty. - YeelightAdoptedDevice persisted record (Id is the stable hex identity, IP is re-resolved every launch). - locale/en.json: 4 new keys (toggle label, toggle description, no-bulbs-found dialog title + body). translate.py --update regenerated the 6 non-EN locales. - Hue/LIFX/Yeelight all stay out of the first-run wizard: the network-discovery flow doesn't fit the wizard shape, and the Settings tab handles the enable path uniformly. Known limitation tracked for v4.2.x follow-up: Hue/LIFX-style adoption picker dialog. Currently auto-adopt covers the "I want this to work" path; the picker would let users pre-select which bulbs Chromatics drives instead of adopting all then disabling unwanted ones via the Mapping tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure-managed AlienFX provider built on HidSharp. Three HID dialects dispatched from one provider; no native bridge DLL, no Dell driver, no Alienware Command Center dependency: - V4 zone (VID 0x187C, OutputReportByteLength 34) — Aurora R7-R14 desktop chassis, m15R1-R6 zone laptops, m17R1, Dell G7/G5/G5SE. Uses the COMMV4_setOneColor / COMMV4_control sequence reverse- engineered from T-Troll's alienfx-tools (MIT licensed). Output reports via HidStream.Write; commit via the "finish and play" control byte (0x03). Lights are flat per-device integer indices (no universal bitmask layout) — we expose them as Custom1..N and let users position them via the Mapping tab. - V5 per-key (VID 0x0D62, Darfon notebooks) — Area51m-R2, x17R2, m15R3 / R4 / R5 / R6, x15R2, m17R3. Uses the COMMV5_colorSet feature-report sequence: 4-byte (id+1, R, G, B) tuples packed up to 15 lights per report, then a Loop marker, then an Update commit. HidStream.SetFeature. - V8 per-key (VID 0x04F2, Chicony external keyboards) — AW510K, AW920K, AW768, AW410K. Two-step protocol: announce frame size via feature report, stream up to 4 lights per 65-byte write report, no explicit commit. HidStream.SetFeature for announce, HidStream.Write for data. Architecture mirrors the QmkRawHid provider: - AlienwareDiscovery: HidSharp enumeration filtered to the three AlienFX VIDs, dispatched into AlienwareApiVersion based on the detection rules from T-Troll's reference SDK. - AlienwareUpdateQueue: per-light RGB diff cache, per-device-disable gate (parity with LIFX/Hue/QMK), dispatches to V4/V5/V8 builders by ApiVersion. V4 batches lights sharing a colour into one HID write to keep frame cost down. - AlienwareUpdateTrigger: 30Hz cap with 50ms idle wake (same shape as Yeelight/QMK trigger) so per-device brightness slider changes propagate when the device is sitting on a static layer. - AlienwareDevice: surfaces N flat LEDs (Custom1..N) — per-key boards don't ship with a (row, col) keymap in T-Troll's open source so semantic Keyboard_* mapping isn't possible without per-board reverse-engineering. Mapping tab handles the placement. - AlienwareRGBDeviceProvider: singleton + adopted-device list, same lifecycle pattern as Yeelight/LIFX/QMK. Wiring: - SettingsModel.deviceAlienwareEnabled + deviceAlienwareAdoptedDevices. - AlienwareAdoptedDevice persists VID/PID/DevicePath/ApiVersion so re-discovery can rebind without re-probing the HID descriptor. - SettingsViewModel toggle row mirrors QMK / Yeelight: auto-adopt on enable, "no devices found" dialog with a hint about closing Alienware Command Center. - RGBController.Setup hydrates ClientDefinitions from settings and runs initial auto-adopt sweep when the persisted list is empty. - locale/en.json: 4 new keys (toggle label, toggle description, no-devices-found dialog title + body). translate.py --update regenerated the 6 non-EN locales. Untestable on this PC (no Alienware hardware available). Build clean, all 130 xUnit tests pass. Real-world validation will land through user feedback after release; expect at least one v4.2.x follow-up patch per detected dialect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yeelight gets a Hue/LIFX-style adoption picker dialog. Enabling the provider opens YeelightAdoptionDialog, runs SSDP discovery, and lets the user choose which bulbs Chromatics drives instead of auto-adopting everything on the LAN. Empty selection or cancel leaves the toggle off. The first-run wizard now includes Hue, LIFX, Yeelight, and Alienware tiles. Each tile triggers its provider's discovery flow inline: - Hue runs the bridge dialog then the bulb picker. - LIFX and Yeelight open their adoption pickers directly. - Alienware enables the provider's local HID discovery on commit. If discovery returns no devices or the user cancels, the tile auto- untoggles via re-entrancy-guarded handlers, and the Continue button stays disabled until at least one provider is configured. Continue persists the captured adoption payloads alongside the existing boolean enable flags. Alienware: - Default ANSI 104 keymap. Per-key V5/V8 boards (AW510K, AW920K, Area51m-R2, m15R3+, m17R3) now expose Keyboard_* LedIds in matrix-scan order pulled from KeyLocalization.QWERTY_Grid, so Highlight / Keybind layers approximately light the right keys out of the box. Lights past the first 104 surface as Custom* in a tail row. Where firmware enumeration order differs from this assumption, keys remap via the Mapping tab. - AWCC conflict detection. If TryOpen fails, the provider enumerates known AWCC process names (AWCC.exe, AlienFXService, LightingService, AlienFXEditor, AlienFusionUpdate, AlienwareCommandCenter) and emits a specific console message naming the running process and how to close it from the system tray. Hue: - Recognise the Hue Play Gradient Lightstrip family (LCX001..006) and render as a strip-shaped LED. Per-zone addressing on gradient strips needs the Hue Entertainment streaming API (DTLS UDP) which Chromatics doesn't yet implement, so the whole strip paints uniformly for now. Documented inline. PlayStation, Hue, LIFX: - Beta tags removed from Settings labels, descriptions, and the first-run wizard. Locale keys for the new non-Beta forms added and translated. Yeelight discovery: - The poll loop no longer raises a first-chance OperationCanceled- Exception on timeout. Replaced await ReceiveAsync(cancellationToken) with a poll on udp.Available + plain Task.Delay, so the no-bulbs- on-the-LAN case stays exception-free in the debugger. Internal cleanup: - Models/<Device>AdoptedDevice.cs files moved into their respective Extensions/RGB.NET/Devices/<Device>/ folders so all per-provider files are co-located. Fixed up references in SettingsModel, RGBController, SettingsViewModel, the Hue/LIFX adoption dialogs and view models, and the harness MainViewModel. Locale: 15 new EN keys added (adoption dialog strings, first-run tooltips, non-Beta toggle labels). translate.py --update regenerated the 6 non-EN locales. chromatics-docs (separate repo, commit 36a1768): Settings page gains full sections for Yeelight, Alienware, and QMK Keyboards with the LAN Control / AWCC / VIA caveats. Troubleshooting page gains "My Yeelight devices aren't showing up", "My Alienware lighting isn't responding", and "My Logitech keyboard or mouse isn't lighting correctly through G HUB" (documenting the G HUB FFXIV game-integration hijack and the Lightsync Windows accent-colour sync issue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yeelight (and the LIFX / Hue dialogs that share its layout) had two
overflow points:
- StatusText sat in a horizontal StackPanel beside the indeterminate
ProgressBar. Long messages like "No Yeelight devices found. Make
sure each bulb has LAN Control enabled in the Yeelight or Mi Home
app." couldn't wrap and ran off the right edge of the dialog.
Replaced with a Grid (* / Auto) so the text wraps and the
ProgressBar stays pinned to the right.
- Per-item Label / Model / IP TextBlocks had no TextWrapping. Long
Yeelight model names ("Yeelight Smart LED Bulb 1S Color") and
user-set bulb names overflowed the row. Added TextWrapping="Wrap"
to all three textblocks across the Yeelight, LIFX, and Hue
picker item templates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Windows Dynamic Lighting (LampArray) device provider built on the WinRT Windows.Devices.Lights.LampArray API. Picks up any device the OS exposes via Settings → Personalization → Dynamic Lighting: Razer flagship peripherals, Logitech G LIGHTSYNC, ASUS ROG, HyperX, MSI, SteelSeries, HP/Omen. Architecture mirrors the QMK / Yeelight provider shape: - DynamicLightingRGBDeviceProvider — singleton with WinRT DeviceWatcher hot-plug. Initial enumeration via FindAllAsync; Added / Removed events drive AddDevice / RemoveDevice across the session. - DynamicLightingDevice — layout pulls per-lamp metadata from the WinRT LampArray. Keyboard-kind devices use LampArray.GetIndicesForKey(VirtualKey) for semantic Keyboard_* mapping (no per-OEM lookup table needed; the OS resolves VirtualKey → physical lamp from the LampArray firmware response). Non-keyboard devices and unmapped keyboard lamps surface as Custom1..N at lamp.Position (Vector3 in mm, projected to 2D for RGB.NET decorators). - DynamicLightingUpdateQueue — 30Hz cap, per-lamp dirty diff cache, batches changed lamps into a single LampArray.SetColorsForIndices call per frame. Skips writes when LampArray.IsConnected || IsEnabled is false (the WinRT surface refuses writes when our app isn't the currently- allowed lighting client; without the gate every paint frame would throw COMException). - DynamicLightingKeyMap — static (LedId, VirtualKey) table covering the standard ANSI 104. **Phase 1 limitation: foreground-only.** Without a sparse signed package declaring the `com.microsoft.windows.lighting` AppExtension, Windows only accepts our writes while Chromatics has foreground focus. During FFXIV gameplay the game has focus and our writes silently no-op. Phase 2 of the Dynamic Lighting work adds the sparse package so background writes are accepted. Toggle description and tooltip flag this honestly. Wiring: - SettingsModel.deviceDynamicLightingEnabled. - SettingsView toggle row + first-run wizard tile. - RGBController.Setup hook. - 3 new EN locale keys + translate.py regenerated the 6 non-EN locales. Build infrastructure: Chromatics, Tests, and DecoratorHarnessUI csproj TFMs bumped from net10.0-windows7.0 to net10.0-windows10.0.17763.0 so Windows.Devices.Lights.LampArray is reachable. Forward-compatible at the .NET runtime level — .NET 10 already requires Windows 10 1809+ as the minimum host OS regardless of the TargetFramework moniker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 (partial) of the Dynamic Lighting work. Per-device exclusive- owner toggle in the Mappings tab and the first-launch hint dialog about Settings → Personalization → Dynamic Lighting are deferred to Phase 2 (sparse package); they don't have meaningful behaviour to attach to until background writes work. Auto-deduplication - DynamicLightingVendorOverlap maps USB vendor ids 0x1532 (Razer), 0x046D (Logitech), 0x0B05 (ASUS), 0x1462 (MSI), 0x1038 (SteelSeries) to the corresponding Chromatics vendor provider. - DynamicLightingRGBDeviceProvider.TryAdoptAsync queries the live LampArray's HardwareVendorId and skips adoption when the matching vendor provider is currently enabled in settings. The vendor SDK retains exclusive control of overlapping devices, which prevents the two providers from racing on the same hardware every frame. - Skip is logged to console with the vendor name + suggested override (disable the vendor provider in Settings). Conflict popup on enable - ShowDynamicLightingOverlapPopupIfNeededAsync runs from MakeDeviceToggle's wrapped load callback for every provider. - For the Dynamic Lighting toggle: lists every overlapping vendor currently enabled. - For the Razer / Logitech / ASUS / MSI / SteelSeries toggles: warns when Dynamic Lighting is on. - Other providers (Corsair, Wooting, Coolermaster, Novation, OpenRGB) are unaffected — they don't overlap with Dynamic Lighting and the helper no-ops for them. - Popup body explains the auto-dedup rule and points users to the future per-device override in the Mappings tab. CI hardening - nuget.org returned 502 mid-restore on commit bb0c9ed; the next run on the same SHA succeeded immediately. CI workflow now wraps the restore step in nick-fields/retry@v3 (3 attempts, 30s wait) to absorb transient feed flakiness without needing a manual re-run. - Workflow comment updated to reflect the TFM bump from net10.0-windows7.0 to net10.0-windows10.0.17763.0. Locale: 2 new EN keys (Provider conflict title + body template), translated into the 6 non-EN locales. chromatics-docs (separate repo, commit 33486e6): Settings page gains a "Windows Dynamic Lighting (Beta)" section covering device coverage, the foreground-only Phase 1 limitation, the auto-dedup rule, and the conflict popup. Troubleshooting page gains three sections covering "devices not showing up", "Razer/Logitech device shows in Windows but not Chromatics" (the auto-dedup behaviour), and "Dynamic Lighting works but goes dark when I open FFXIV" (the foreground-only Phase 1 limitation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(v4.2.6)
Pulls together the user-flagged refinements to the Dynamic Lighting
provider that landed in v4.2.5.
Device naming
- Devices now show in the Mappings tab as "{Vendor} {Model} (Dynamic
Lighting)" instead of the bare model string Windows hands us
("G512" → "Logitech G512 (Dynamic Lighting)"). The "(Dynamic
Lighting)" suffix disambiguates the same physical device's
Dynamic Lighting entry from its vendor SDK entry. Vendor prefix
comes from a USB-VID lookup table covering Razer, Logitech, ASUS,
MSI, SteelSeries, HyperX, HP, Alienware, Corsair, Cooler Master,
Holtek, and Apple.
Empty-result handling
- If Windows enumerates zero Dynamic Lighting devices, the toggle
flips back off and surfaces a localised "No Dynamic Lighting
devices found" dialog explaining what to check in Settings →
Personalization → Dynamic Lighting. Mirrors the LIFX / Yeelight
empty-result UX.
Conflict-handling default inverted
- The previous v4.2.5 auto-deduplication (vendor SDK silently wins
on overlapping devices) is now opt-in rather than the default.
Default behaviour adopts every device Windows lists regardless
of overlap, which is what most users running Dynamic Lighting
want. Users who hit flickering can opt into the conservative
behaviour via a new Advanced setting.
Advanced bypass toggle
- New checkbox in Settings → Advanced: "Allow Dynamic Lighting to
control devices already covered by another Chromatics provider"
(default: on). Visible only when the Dynamic Lighting provider
is enabled; hidden otherwise.
- SettingsViewModel.DynamicLightingEnabled / DynamicLightingBypassConflictCheck
properties added with a RefreshDynamicLightingState() helper
the toggle row calls into so the Advanced row's visibility
stays in lock-step with the provider state.
Settings schema bump
- AppSettings.currentSettingsVersion bumped to "3" for the three
new fields (deviceDynamicLightingEnabled,
dynamicLightingHintShown, dynamicLightingBypassConflictCheck).
Documentation text revision
- Toggle description, first-run tile tooltip, hint dialog, and
console messages no longer reference "OS" (replaced with
"Windows" since this is a Windows-specific feature) and no
longer carry "Phase 1 / coming in a follow-up" caveats. Hint
dialog now reads as setup instructions rather than apology.
Locale: 12 new EN keys (no-devices dialog, bypass-conflict label
+ tooltip, hint-dialog body + step list, Open Windows Settings
button), translated into the 6 non-EN locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "copy all layers from device A to device B" workflow to the Mappings tab. Trigger - Small copy-icon Button in the per-device toolbar that sits at the bottom-right of the virtual device preview, next to the lock / reset / brightness controls. Two overlapping rounded rectangles drawn via Path so it reads as a standard copy affordance at toolbar size without needing an emoji or icon font. Dialog (CopyLayersDialog) - Source device combo (defaults to the currently-selected device). - Destination device combo, filtered by LayerCopier.IsCopyAllowed: keyboards only see keyboards; anything else can pick any non-keyboard destination. - Scrollable list of source-LED -> destination-LED mappings with per-row override via combo. Default mapping for keyboard-pair copies is identity by LedId (Escape -> Escape, Keyboard_A -> Keyboard_A, etc.). For non-keyboard pairs it tries exact LedId match first and falls back to positional-index match for LedIds the destination doesn't expose. - Summary line tells the user how many layers will be copied and how many source LEDs have no destination mapping (will be dropped on copy). Engine (LayerCopier.Apply) - Snapshots existing source-device layers via MappingLayers.GetLayers. - For each, calls MappingLayers.AddLayer with the dest device's GUID + type, the same root / dynamic type / zindex / Enabled flag / allowBleed / layerModes, and a deviceLeds dict that remaps every source LedId through the resolved mapping. - Original source-device layers are not touched. - Persists via MappingLayers.SaveMappings before returning. - Returns a CopyResult naming layers copied and dropped LED mappings so the dialog can surface both in its completion popup. Refresh - Dialog code-behind asks the host MappingViewModel to RefreshLayers() on apply so the new destination layers surface in the layer list view without a manual reload. Locale: 13 new EN keys (button tooltip, dialog title + body, source/dest pickers, mapping headers, summary + completion templates, Copy / Cancel buttons). translate.py regenerated the 6 non-EN locales. chromatics-docs (separate repo, commit c1cee21): new "Copying layers between devices" section under Mappings, plus a sweep through the Dynamic Lighting Settings + Troubleshooting text to drop the Phase / foreground-only / "OS" language now that the provider and its limitations are documented in the present tense. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy-layers - LayerCopier reworked from "LED-by-LED across all layers" to per-layer. Caller builds a list of LayerCopyPlan entries (one per source layer they ticked, with that layer's LedId mapping) and hands it to Apply. - Base and Effect layers are at-most-one per device. When the destination already has a layer of that root type, Apply removes the existing one before adding the copy. Dialog surfaces this as a "Replaces the existing layer of this type on the destination" subline so the user knows what's about to happen. - Dynamic layers are stacked. Apply appends without touching pre-existing Dynamic layers. - Per-layer key mapping is exposed only for Dynamic layers (Base / Effect cover the whole device with implicit identity / positional mapping). Mapping rows are scoped to the LedIds that layer actually uses (layer.deviceLeds.Values), so the user doesn't have to scroll past every key on the keyboard to remap the keys for an HP Tracker layer. - Dialog UI: row-per-layer with checkbox + display name + type badge + replacement warning + collapsible expander carrying the per-LED mapping list for Dynamic layers. - ComputeDefaultMappingForLayer scopes the default mapping to the supplied set of source LedIds. Identity match for keyboard-pair copies; exact-LedId-first / positional fallback for cross-type or non-keyboard pairs. Dynamic Lighting bypass default flipped - dynamicLightingBypassConflictCheck now defaults to false. The conservative auto-deduplication (vendor SDK wins on overlap) is on by default; users opt INTO the bypass via the Advanced toggle if they want Dynamic Lighting to claim every Windows-listed device regardless of vendor overlap. - Advanced tooltip + locale string updated to reflect the new default and explain the flickering risk users sign up for when they tick it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four refinements to the copy-layers dialog:
- **Dynamic-only.** Base and Effect layers are no longer offered for
copy. They're at-most-one per device by design and belong to the
destination device's own setup; the previous "replace existing"
flow was more confusing than useful. ViewModel.RebuildLayerRows
now filters the source layer list to LayerType.DynamicLayer.
- **Select all / Clear all buttons.** A small pair above the layer
list flips every row at once. Hidden when the source has no
dynamic layers (so the empty-state message has the panel to
itself).
- **Copy button gated on a non-empty selection.** New
CanCopy ObservableProperty on the view-model, recomputed inside
UpdateSummary. The Copy button's IsEnabled binds to it. Prevents
the "no layers selected" alert path that previously fired only
on click.
- **Empty-state message.** When the source device has no dynamic
layers, the bounding box shows a centred italic hint pointing
the user back to the Mappings tab to add a dynamic layer first.
Replaces the previous generic "no layers to copy" text.
Per-row UI tidy: dropped the type badge column and the "replaces
existing layer" subline (both were only meaningful while Base /
Effect were in the list). Each row is now checkbox + display
name + collapsible per-key mapping expander.
Success-dialog template simplified to reflect the Dynamic-only
scope ("Copied N dynamic layer(s) ... M LEDs skipped" rather
than the previous template that also mentioned replacement
counts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match the order the Mappings tab uses (`OrderByDescending(l => l.zindex)` from MappingViewModel.RefreshLayers) so the top-most layer in the user's Mappings list is the top row in the copy dialog. Previous ordering by `layerIndex` (insertion order) didn't track when the user dragged layers around in the Mappings tab to reorder them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Since RebuildLayerRows now filters source layers to LayerType.DynamicLayer only, every row in the copy dialog is necessarily a Dynamic layer. Prefixing each row's label with "Dynamic:" just repeats information that's true of the entire list, so simplify BuildLayerDisplayName to return just the sub-type name for Dynamic layers (e.g. "HPTracker", "Highlight", "Keybinds") with no category prefix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- AXAML: dropped the IsVisible binding so the checkbox shows unconditionally in Settings -> Advanced. - AXAML: relabelled "Allow Dynamic Lighting to control devices already covered by another Chromatics provider." to "Windows Dynamic Lighting: allow control of devices already covered by another Chromatics provider." so the row's scope is obvious when scanning the Advanced list. - ViewModel: removed the now-unused DynamicLightingEnabled observable property, the _dynamicLightingEnabled backing field, and the RefreshDynamicLightingState() method that kept it in sync from the device-toggle row's enable / disable / empty-result callbacks. The checkbox is unconditionally visible now so the cross-property sync isn't needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Grants Chromatics package identity via PackageManager.AddPackageByUriAsync with ExternalLocationUri, so AmbientLightingServer accepts it as a background lighting controller on Win11 22H2+. Dynamic Lighting now drives compatible devices during FFXIV gameplay instead of only while Chromatics has foreground focus. Registration is tied to the DL provider toggle: enabling it calls SparsePackageRegistrar.EnsureRegisteredAsync, disabling (or the empty-result auto-disable path) calls DeregisterAsync, and Velopack's OnBeforeUninstallFastCallback handles the uninstall path as a safety net. All callsites are guarded with OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041) so Win10 1809-1909 users keep foreground-only DL working. TFM bumped from net10.0-windows10.0.17763.0 to net10.0-windows10.0.19041.0 across all three csprojs for AddPackageOptions.ExternalLocationUri visibility; SupportedOSPlatformVersion stays at 10.0.17763.0 so the runtime floor is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…heck
Fan the build/test out across both currently-supported Windows runner
images so platform-specific regressions surface in CI rather than on a
user's box. Adds a makeappx pack step that validates the Dynamic Lighting
sparse-package manifest schema after substituting the {{VERSION}}
placeholder, catching capability-name typos / namespace drift before they
silently fail AddPackageByUriAsync at runtime.
windows-2019 (build 17763) was retired by GitHub Actions in mid-2025, so
SupportedOSPlatformVersion=10.0.17763.0 cannot be exercised in CI today;
the OS-guarded skip path is validated locally instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The workflow only checks out the repo and runs build/test/manifest validation locally — no GitHub API writes are needed, so contents: read is the minimum sufficient permission. Closes the GitHub Advanced Security finding about unrestricted token scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A commit on a PR branch fires both a push event and a pull_request: synchronize event today, doubling runner time. Grouping both events under the same key (resolved from pull_request.head.ref on PR events and ref_name on push events) with cancel-in-progress lets the newer trigger cancel the older in-flight run, so one CI completion lands per commit instead of two. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR collects the v4.1.44 → v4.2.7 work into the
chromatics-4.xbranch ready for merge tomaster. New device providers (QMK, Yeelight, Alienware, Windows Dynamic Lighting), per-device tooling (copy layers between devices, first-run network discovery, adoption pickers), and a sweep of supporting infrastructure work (CI retry hardening, AdoptedDevice file reorganisation, locale + docs sync).v4.1.44 — QMK Raw HID
v4.1.45 — Logitech layout / 0-LED fallback
KeyLocalization.QWERTY_Grid). Dropped the archived RGB.NET-Resources XML dependency.Layouts/directory removed from the installer.v4.2.0 / v4.2.1 — Yeelight + Alienware
Models/into their provider folders so per-provider code is co-located.v4.2.2 / v4.2.3 — UX polish
v4.2.4 / v4.2.5 / v4.2.6 — Windows Dynamic Lighting
LampArray.GetIndicesForKey(VirtualKey)."{Vendor} {Model} (Dynamic Lighting)"so it's distinct from the same physical device's vendor SDK entry. Vendor prefix sourced from a USB-VID lookup table covering Razer, Logitech, ASUS, MSI, SteelSeries, HyperX, HP, Alienware, Corsair, Cooler Master."2"to"3"for the three new persisted fields.nick-fields/retry@v3on the restore step.v4.2.7 — Copy layers between devices
Infrastructure
net10.0-windows7.0tonet10.0-windows10.0.17763.0so theWindows.Devices.Lights.LampArrayAPI is available. Forward-compatible — .NET 10 already required Windows 10 1809+ at runtime regardless.AdoptedDevicefiles for every provider moved fromModels/into their respectiveExtensions/RGB.NET/Devices/<X>/folders.chromatics-docs: full sections for QMK, Yeelight, Alienware, Dynamic Lighting in the Settings page; troubleshooting sections covering Yeelight LAN Control, Alienware AWCC conflicts, Logitech G HUB FFXIV-integration hijack + Lightsync Windows accent-colour issues, Dynamic Lighting empty-enumeration and conflict handling; new "Copying layers between devices" section under Mappings.Deferred for a separate session
Test plan
chromatics-4.xhead (36c6887)🤖 Generated with Claude Code