Skip to content

v4.2.x (Beta): Yeelight + Alienware + Dynamic Lighting + Copy Layers + QMK + Logitech fixes#193

Open
logicallysynced wants to merge 30 commits into
masterfrom
chromatics-4.x
Open

v4.2.x (Beta): Yeelight + Alienware + Dynamic Lighting + Copy Layers + QMK + Logitech fixes#193
logicallysynced wants to merge 30 commits into
masterfrom
chromatics-4.x

Conversation

@logicallysynced
Copy link
Copy Markdown
Owner

@logicallysynced logicallysynced commented May 14, 2026

Summary

This PR collects the v4.1.44 → v4.2.7 work into the chromatics-4.x branch ready for merge to master. 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

  • New device provider for QMK-firmware keyboards over Raw HID. Two-protocol handshake (VIA + OpenRGB-QMK plugin). 2650-board keymap database bundled as an embedded resource. First-run tile + auto-adopt + no-boards-found dialog. Dependabot bumps for Avalonia 12.0.3 + System.Drawing.Common 10.0.8 rolled in.

v4.1.45 — Logitech layout / 0-LED fallback

  • Logitech conical gradient fixed via algorithmic position inference (KeyLocalization.QWERTY_Grid). Dropped the archived RGB.NET-Resources XML dependency.
  • 0-LED keyboards and headsets now get an algorithmically-synthesised default key grid. Bundled Layouts/ directory removed from the installer.

v4.2.0 / v4.2.1 — Yeelight + Alienware

  • Yeelight LAN provider with SSDP discovery, Music Mode handshake (removes the 60-commands-per-minute cap), dual-light bulb support (Bedside Lamp 2 as two LEDs), 40+ models recognised, full adoption picker dialog mirroring LIFX.
  • Alienware AlienFX provider — pure managed HidSharp, no Dell driver. Covers V4 zone chassis (Aurora R7-R14, Dell G-series), V5 per-key notebooks (m15R3+, m17R3, Area51m-R2), V8 per-key external (AW510K, AW920K, AW768, AW410K). Default ANSI 104 keymap + AWCC conflict detection.
  • PlayStation / Hue / LIFX promoted out of Beta.
  • AdoptedDevice files relocated from Models/ into their provider folders so per-provider code is co-located.
  • Hue, LIFX, Yeelight, Alienware tiles added to the first-run wizard with inline discovery flows.

v4.2.2 / v4.2.3 — UX polish

  • Adoption dialog text wrapping fixed across Yeelight, LIFX, and Hue (StatusText area + per-item Label / Model rows).

v4.2.4 / v4.2.5 / v4.2.6 — Windows Dynamic Lighting

  • Windows Dynamic Lighting (LampArray) provider. Picks up every device Windows lists in Settings → Personalization → Dynamic Lighting (Razer, Logitech G LIGHTSYNC, ASUS ROG, HyperX, MSI, SteelSeries, HP/Omen). DeviceWatcher-driven discovery + hot-plug. Semantic Keyboard_* mapping via LampArray.GetIndicesForKey(VirtualKey).
  • Each Dynamic Lighting device shows in the Mappings tab as "{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.
  • First-launch hint dialog walking through Settings → Personalization → Dynamic Lighting, with an "Open Windows Settings" deep-link button.
  • Advanced toggle: "Allow Dynamic Lighting to control devices already covered by another Chromatics provider" (default: on). Only visible when Dynamic Lighting is enabled. Default behaviour is bypass-on (DL adopts everything regardless of vendor overlap); users who see flickering can opt into the conservative mode where the vendor SDK wins on overlap.
  • Auto-disable on empty enumeration. If Windows lists zero Dynamic Lighting devices when the toggle is flipped on, the toggle flips back off and a localised dialog explains what to check.
  • Settings schema bumped from "2" to "3" for the three new persisted fields.
  • CI workflow hardened against transient nuget.org 502s via nick-fields/retry@v3 on the restore step.

v4.2.7 — Copy layers between devices

  • Small copy-icon button in the per-device toolbar opens a dialog that duplicates every layer from one device onto another. Source / destination pickers, per-LED override list, summary of layers copied + LEDs dropped.
  • Keyboards constrained to copy onto keyboards (consistent ANSI 104 layout matches by LedId across vendors). Other device types can cross-copy (mouse → headset, chassis → strip).

Infrastructure

  • TFM bumped across all three projects from net10.0-windows7.0 to net10.0-windows10.0.17763.0 so the Windows.Devices.Lights.LampArray API is available. Forward-compatible — .NET 10 already required Windows 10 1809+ at runtime regardless.
  • AdoptedDevice files for every provider moved from Models/ into their respective Extensions/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

  • Sparse signed package + AppX manifest for Dynamic Lighting background writes during gameplay. Tracked separately because it needs a code-signing cert + Windows SDK install on the dev machine first. IV Authenticode is sufficient when ready.

Test plan

  • Build clean on net10.0-windows10.0.17763.0
  • 130/130 xUnit tests pass
  • CI green on chromatics-4.x head (36c6887)
  • Enable QMK provider with no boards / with a VIA board / with an OpenRGB-QMK board
  • Enable Yeelight provider with no bulbs / with bulbs (LAN Control on) / with a Bedside Lamp 2 (two LEDs)
  • Enable Alienware provider with no AlienFX hardware / with AWCC running (named in console) / with a V4 / V5 / V8 device
  • Enable Dynamic Lighting provider with no devices (auto-untoggle + dialog) / with devices (vendor-prefixed names)
  • Dynamic Lighting Advanced toggle visible only when DL is enabled, hides when disabled
  • Copy layers between two keyboards (identity mapping) / two non-keyboards (positional fallback) / cross-type non-keyboard (mouse → headset)
  • First-run wizard with each network provider discovery flow

🤖 Generated with Claude Code

logicallysynced and others added 7 commits May 14, 2026 20:21
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>
@logicallysynced logicallysynced self-assigned this May 14, 2026
logicallysynced and others added 3 commits May 14, 2026 21:32
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>
@logicallysynced logicallysynced changed the title v4.1.44: QMK Raw HID keyboard support (Beta) + Avalonia 12.0.3 + obj cleanup v4.1.44 + v4.1.45: QMK Raw HID (Beta), Logitech layout inference, Avalonia 12.0.3, obj cleanup May 16, 2026
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>
@logicallysynced logicallysynced changed the title v4.1.44 + v4.1.45: QMK Raw HID (Beta), Logitech layout inference, Avalonia 12.0.3, obj cleanup v4.2.0 (Beta): Yeelight LAN devices + QMK Raw HID + Logitech layout fixes May 16, 2026
logicallysynced and others added 2 commits May 16, 2026 13:50
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>
@logicallysynced logicallysynced changed the title v4.2.0 (Beta): Yeelight LAN devices + QMK Raw HID + Logitech layout fixes v4.2.1 (Beta): Alienware AlienFX + Yeelight LAN + QMK Raw HID + Logitech fixes May 16, 2026
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>
@logicallysynced logicallysynced changed the title v4.2.1 (Beta): Alienware AlienFX + Yeelight LAN + QMK Raw HID + Logitech fixes v4.2.x (Beta): Yeelight + Alienware + first-run network discovery + Logitech G HUB docs May 16, 2026
logicallysynced and others added 7 commits May 16, 2026 14:28
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>
@logicallysynced logicallysynced changed the title v4.2.x (Beta): Yeelight + Alienware + first-run network discovery + Logitech G HUB docs v4.2.x (Beta): Yeelight + Alienware + Dynamic Lighting + Copy Layers + QMK + Logitech fixes May 16, 2026
logicallysynced and others added 3 commits May 16, 2026 17:44
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>
logicallysynced and others added 4 commits May 16, 2026 18:04
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>
Comment thread .github/workflows/ci.yml Fixed
logicallysynced and others added 2 commits May 16, 2026 23:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants