diff --git a/LICENSES.md b/LICENSES.md index 9043232..4a1237d 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -9,6 +9,35 @@ release build and their licenses. ## MIT License +### LCD Fusion (Valkyrie V360 protocol initialization) +- **Copyright**: © 2026 itsmylife44 +- **Project**: https://github.com/itsmylife44/LcdFusion +- **Notice**: Also retained in `src/InfoPanel.Devices.VmaxPanel/ValkyrieV360Protocol.cs`. + +```text +MIT License + +Copyright (c) 2026 itsmylife44 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ### Avalonia (including Avalonia.Desktop, Avalonia.Controls.ColorPicker, Avalonia.Fonts.Inter) - **Copyright**: © AvaloniaUI OÜ and contributors - **Project**: https://avaloniaui.net/ diff --git a/README.md b/README.md index 68b8f38..7aa16e3 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,17 @@ CDC serial JPEG protocol at 2 Mbaud (VID `33C3`): |---|---|---| | VMAX 4.6" LCD (AuyiHomu HY-001, HY-002) | 320×960 | `345F:9132` | +### Valkyrie + +| Model | Resolution | USB ID | +|---|---|---| +| Valkyrie V360 LCD ✓ | 320×240 | `345F:9132` | +| Valkyrie MC360 LCD (experimental) | 640×480 | `374A:A021`, `Vk-Copl-08` | + +See [V360 protocol and hardware validation](docs/VALKYRIE-V360.md) and +[MC360 protocol and validation limits](docs/VALKYRIE-MC360.md). Only identified +retail models are listed; matching a screen size alone does not add compatibility. + ### Jonsbo Displays shipped with Jonsbo AIO coolers; protocols reverse-engineered from @@ -326,8 +337,8 @@ the OEM JONSBO-AIO app: | Jonsbo DS916 ✓ | 462×1920 | Serial `33C3:F101` (HLVMAX, raw JPEG frames) | | Jonsbo DS339 ✓ | 376×960 | USB `345F:9132` (MacroSilicon MS9132, HID + bulk UYVY422) | -The DS339 shares its USB ID with the VMAX 4.6"; the device scan reads the -panel EDID to tell them apart. +The DS339 shares its USB ID with the VMAX 4.6" and Valkyrie V360; the device +scan reads the panel EDID to tell them apart. ### Turing Smart Screen diff --git a/docs/VALKYRIE-MC360.md b/docs/VALKYRIE-MC360.md new file mode 100644 index 0000000..49f777c --- /dev/null +++ b/docs/VALKYRIE-MC360.md @@ -0,0 +1,64 @@ +# Valkyrie MC360 LCD + +This is an **experimental Linux implementation** for one identified retail model. +The device title is `Valkyrie MC360 LCD`; the device-row subtitle marks its +experimental status. No physical MC360 has been tested with this implementation. +The existing V360 hardware-tested HID/UYVY path remains separate. + +## Identification + +All of these must match before a device can be added or started: + +- USB `374A:A021`, product string `Vk-Copl-08` (case-insensitive). +- A valid mode-2 response to the open handshake and screen-info command. +- Explicit screen-info width 640, height 480, framebuffer length 614400 bytes. +- The `F001` pixel-format variant is rejected. + +A serial, when available, selects the exact device. Otherwise its USB device path +is used. The screen geometry is queried again on every connection. Other BoWei +PIDs, other product strings and other geometries are not assigned an MC360 name. +Discovery only handshakes and reads info; it does not set brightness or send images. +The `374a:a021` USB udev rule covers this model; there is no HID requirement. + +## Wire contract + +Interface 0 uses bulk OUT `02` and bulk IN `81`. Requests have a little-endian +16-bit command and 32-bit payload length. Responses must echo command OR `8000`, +with the declared and actual response lengths matching. Four-byte nonzero status +responses fail the operation. + +Open sends command `1003`, payload `45 23 01 00`, then command `0001` for the +28-byte screen-info structure. Width, height and framebuffer length are at byte +offsets 12, 16 and 24. A streaming connection sets brightness (`0002`) to 100; +the profile brightness control then dims pixels in software. + +A frame is little-endian BGR565. Command `1001` announces its four-byte byte count; +only after its successful acknowledgement are raw pixels sent in chunks up to +`20000` hex bytes. Every transfer aligned to `1000` hex bytes gets a zero-length +packet. Static profiles update when content/options change; idle sessions send +`000F` with `78 56 34 12`. Screen-off sends a black image; exit retains the last +image and releases interface 0, without rebooting or sending the vendor close +command that blanks the screen. + +## Evidence and scope + +- The [MythCool-Lite compatibility table](https://github.com/ZGMFX01A/MythCool-Lite/blob/342f8a330a5e8d83e229395fecf11bafd56a1158/README.md#硬件与驱动兼容性清单) + identifies MC360, its USB descriptor and 640×480 geometry, with author-reported + physical validation on Windows. Its current device core is private. +- [Published BoWei wire research](https://github.com/codehz/msdisplay/blob/34dd469d83c199a16832994755d2352495148a4e/bowei_aio/display.py) + documents A021 mode-2 packets and BGR565 framing. This transport was written + independently from those protocol facts; it does not include that source, + a private dependency, vendor binaries or firmware. +- Automated tests cover descriptor and geometry restrictions, command byte order, + malformed replies, screen-info rejection, BGR565 channel order and row stride, + transfer chunk/ZLP order, failed acknowledgements and XML settings. + +These checks verify software behavior, not the physical MC360 display. A tester +must still confirm initialization, colors, orientation, static-image retention, +brightness, screen-off, reconnect and suspend/resume on Linux. + +The catalog deliberately excludes B360/B360-GT shared material-code profiles and +unmapped MS resolutions. The [vendor download page](https://valkyrie.com.cn/) +mentions R/AL/ML/DX air-cooler families, but a family name or advertised resolution +is insufficient to assign a protocol to a retail model. No air-cooler entry is +added without an explicit model-to-protocol mapping. diff --git a/docs/VALKYRIE-V360.md b/docs/VALKYRIE-V360.md new file mode 100644 index 0000000..0c70d4b --- /dev/null +++ b/docs/VALKYRIE-V360.md @@ -0,0 +1,58 @@ +# Valkyrie V360 LCD (320×240) + +The V360 screen uses the MacroSilicon MS9132 bridge (`345f:9132`). It is managed +under its own **Valkyrie** section, reusing the existing UYVY renderer and device +supervisor. Valkyrie devices have a separate device list and streaming switch; +previous Valkyrie entries in the VMAX collection migrate with their settings and hotkeys. + +## Identification and access + +The same USB ID appears on several unrelated screens. Discovery reads the first +EDID detailed timing descriptor: 320×240 selects V360, 320×960 selects VMAX, +and 376×960 belongs to the Jonsbo DS339 path. Unknown or unreadable geometry is skipped instead of defaulting to Jonsbo or VMAX. +A confirmed V360 scan repairs a matching stale DS339 configuration. A stable USB serial pairs the HID control +interface with the video interface; V360 open rechecks the geometry before init. +The existing `345f:9132` udev rules cover both USB and hidraw nodes. RPM installation +reapplies the rules to already-connected V360/MC360 USB and HID nodes. + +## Transport + +- HID feature reports use LinuxHidRawFeature, retaining the kernel HID driver. + Both ioctl buffers include report ID 0 plus eight payload bytes. +- The V360-specific 77-command initialization is followed by complete UYVY frames + on interface 3 / bulk OUT endpoint 0x04. +- Each frame is 153616 bytes: `FF 00 00 00 00 14 00 F0`, 153600 UYVY pixel bytes, + and `FF C0 00 00 00 00 00 00`. +- Only after the first successful complete frame are the post-frame commands sent: + `B5 F5 07` (read), `B6 F5 07 00`, `A6 05 01 00`, `B5 C5 55` (read). +- `B5 00 32` is polled every 30 subsequent frames. Static profiles are resent, + and brightness/rotation/profile changes invalidate the cached conversion. +- Brightness is software dimming. JPEG quality is irrelevant to raw UYVY and is + hidden in this family's panel options. No pump/fan/RGB commands are sent. + +## Validation + +I tested a standalone Linux implementation on my V360 under Fedora 44 / Linux 7.2 +and confirmed that its test clock, text and color bars displayed correctly. +Its 77-command initialization was compared byte-for-byte with LCD Fusion. +The regression fixture digest includes every padded payload and read-reply flag. + +I then tested the integrated InfoPanel AppHost and profile renderer on the same +hardware. Discovery selected V360 and excluded the Jonsbo path. My profile with +text, a live clock and CPU temperature streamed at approximately 9 FPS with a +10 FPS target, and I confirmed correct colors and orientation. I changed and +restored software brightness and 180-degree rotation during streaming. I also +verified that stopping and restarting the device task reacquired both interfaces +and that clean shutdown released them. I have not tested physical suspend/resume. + +The solution test suite passes 361 tests, including geometry routing, the +initialization trace digest, post-frame command order, XML settings round-trip, +separate family switches, stale-configuration migration and device-page grouping. +Other Valkyrie models or panels using the same USB ID have not been verified. +The [MC360](VALKYRIE-MC360.md) has a separate experimental bulk transport and does +not use this initialization. Unmapped generic resolutions are not model entries. + +## Attribution + +The initialization sequence is adapted from [LCD Fusion](https://github.com/itsmylife44/LcdFusion), +MIT © 2026 itsmylife44. The full notice is preserved in `ValkyrieV360Protocol.cs`. diff --git a/packaging/infopanel-udev.rules b/packaging/infopanel-udev.rules index 7d552dd..50cecc5 100644 --- a/packaging/infopanel-udev.rules +++ b/packaging/infopanel-udev.rules @@ -75,6 +75,9 @@ KERNEL=="hidraw*", ATTRS{idVendor}=="26ce", ATTRS{idProduct}=="0a11", MODE="0666 # access without dialout membership; both are Artinchip 33c3 SKUs) SUBSYSTEM=="tty", ATTRS{idVendor}=="33c3", MODE="0666" -# VMAX / AuyiHomu 4.6" LCD (HY-001, HY-002) and Jonsbo DS339 (both MacroSilicon MS9132) +# VMAX / AuyiHomu 4.6" LCD, Jonsbo DS339 and Valkyrie V360 (MacroSilicon MS9132) SUBSYSTEM=="usb", ATTR{idVendor}=="345f", ATTR{idProduct}=="9132", MODE="0666" KERNEL=="hidraw*", ATTRS{idVendor}=="345f", ATTRS{idProduct}=="9132", MODE="0666" + +# Valkyrie MC360 LCD (mode-2 bulk protocol, experimental) +SUBSYSTEM=="usb", ATTR{idVendor}=="374a", ATTR{idProduct}=="a021", MODE="0666" diff --git a/packaging/rpm/infopanel.spec b/packaging/rpm/infopanel.spec index 51239be..ae599c3 100644 --- a/packaging/rpm/infopanel.spec +++ b/packaging/rpm/infopanel.spec @@ -39,6 +39,20 @@ if [ -d /run/systemd/system ]; then fi if [ -S /run/udev/control ]; then udevadm control --reload-rules || : + # Apply permissions to already-connected Valkyrie/shared MS9132 LCDs. + # Reloading rules alone only affects the next plug-in event. + for device in /sys/bus/usb/devices/*; do + [ -r "$device/idVendor" ] && [ -r "$device/idProduct" ] || continue + read -r vendor < "$device/idVendor" + read -r product < "$device/idProduct" + case "$vendor:$product" in + 345f:9132|374a:a021) + udevadm trigger --action=change "$device" || : + udevadm trigger --action=change --subsystem-match=hidraw --parent-match="$(readlink -f "$device")" || : + ;; + esac + done + udevadm settle --timeout=5 || : fi %preun diff --git a/src/InfoPanel.App/AppHost.cs b/src/InfoPanel.App/AppHost.cs index 8462721..8cbf7ce 100644 --- a/src/InfoPanel.App/AppHost.cs +++ b/src/InfoPanel.App/AppHost.cs @@ -101,6 +101,7 @@ public void Initialize() // Configuration Settings = ConfigPersistence.LoadSettings() ?? new Settings(); MigrateLianLiDevices(); + if (ValkyriePanelSettings.Migrate(Settings)) SaveSettings(); foreach (var profile in ConfigPersistence.LoadProfiles() ?? []) { Profiles.Add(profile); @@ -152,8 +153,9 @@ public void Initialize() if (Settings.JlPanelMultiDeviceMode) await JlPanelTask.Instance.StartAsync(); break; case nameof(Settings.VmaxPanelMultiDeviceMode): + case nameof(Settings.ValkyriePanelMultiDeviceMode): await VmaxPanelTask.Instance.StopAsync(); - if (Settings.VmaxPanelMultiDeviceMode) await VmaxPanelTask.Instance.StartAsync(); + if (Settings.VmaxPanelMultiDeviceMode || Settings.ValkyriePanelMultiDeviceMode) await VmaxPanelTask.Instance.StartAsync(); break; case nameof(Settings.JonsboPanelMultiDeviceMode): await JonsboPanelTask.Instance.StopAsync(); diff --git a/src/InfoPanel.App/Services/HotkeyManager.cs b/src/InfoPanel.App/Services/HotkeyManager.cs index c9d9c46..c086e5a 100644 --- a/src/InfoPanel.App/Services/HotkeyManager.cs +++ b/src/InfoPanel.App/Services/HotkeyManager.cs @@ -223,6 +223,7 @@ private void ApplyHotkey(HotkeyBinding binding) "Thermalright" => SetProfile(_settings.ThermalrightPanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), "Thermaltake" => SetProfile(_settings.ThermaltakePanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), "Jl" => SetProfile(_settings.JlPanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), + "Valkyrie" => SetProfile(_settings.ValkyriePanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), "Vmax" => SetProfile(_settings.VmaxPanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), "Jonsbo" => SetProfile(_settings.JonsboPanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), "LianLi" => SetProfile(_settings.LianLiPanelDevices.FirstOrDefault(d => d.DeviceId == binding.DeviceId), d => d.ProfileGuid = binding.ProfileGuid), diff --git a/src/InfoPanel.App/Services/PanelDiscoveryReconciler.cs b/src/InfoPanel.App/Services/PanelDiscoveryReconciler.cs new file mode 100644 index 0000000..e4147bf --- /dev/null +++ b/src/InfoPanel.App/Services/PanelDiscoveryReconciler.cs @@ -0,0 +1,81 @@ +using InfoPanel.JonsboPanel; +using InfoPanel.Models; +using InfoPanel.VmaxPanel; + +namespace InfoPanel.Services; + +/// Apply confirmed discovery results, repairing earlier MS9132 misidentification. +public static class PanelDiscoveryReconciler +{ + public static (int Added, int Updated) Apply(Settings settings, + IEnumerable jonsbo, IEnumerable displays) + { + ValkyriePanelSettings.Migrate(settings); + int added = 0, updated = 0; + foreach (var found in jonsbo) + { + if (found.Model == JonsboPanelModel.DS339 && !found.Ms9132Confirmed) continue; + var existing = settings.JonsboPanelDevices.FirstOrDefault(d => Same(d.DeviceId, found.DeviceId)); + if (existing == null) + { + settings.JonsboPanelDevices.Add(new JonsboPanelDevice + { + DeviceId = found.DeviceId, DeviceLocation = found.DeviceLocation, Model = found.Model, + }); + added++; + } + else + { + existing.DeviceLocation = found.DeviceLocation; + existing.Model = found.Model; + updated++; + } + } + foreach (var found in displays) + { + var isValkyrie = found.ModelInfo?.IsValkyrie == true; + var family = isValkyrie ? settings.ValkyriePanelDevices : settings.VmaxPanelDevices; + var wasEmpty = family.Count == 0; + var existing = family.FirstOrDefault(d => Same(d.DeviceId, found.DeviceId)); + // Only confirmed V360 geometry permits removing the old erroneous DS339. + var stale = found.Model == VmaxPanelModel.ValkyrieV360 + ? settings.JonsboPanelDevices.FirstOrDefault(d => d.Model == JonsboPanelModel.DS339 && Same(d.DeviceId, found.DeviceId)) + : null; + if (existing == null) + { + existing = new VmaxPanelDevice + { + DeviceId = found.DeviceId, DeviceLocation = found.DeviceLocation, Model = found.Model, + TargetFrameRate = isValkyrie ? 10 : 1, + // Preserve assignments, but don't start a previously disabled device. + ProfileGuid = stale?.ProfileGuid ?? Guid.Empty, + Enabled = stale?.Enabled ?? false, + Brightness = stale?.Brightness ?? 99, + }; + family.Add(existing); + added++; + } + else + { + existing.DeviceLocation = found.DeviceLocation; + existing.Model = found.Model; + updated++; + } + if (stale != null) + { + settings.JonsboPanelDevices.Remove(stale); + foreach (var binding in settings.HotkeyBindings.Where(b => b.DeviceType == "Jonsbo" && Same(b.DeviceId, stale.DeviceId))) + binding.DeviceType = "Valkyrie"; + if (settings.JonsboPanelDevices.Count == 0) settings.JonsboPanelMultiDeviceMode = false; + } + if (wasEmpty) + { + if (isValkyrie) settings.ValkyriePanelMultiDeviceMode = true; + else settings.VmaxPanelMultiDeviceMode = true; + } + } + return (added, updated); + } + + private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/InfoPanel.App/ViewModels/ProfileCardViewModel.cs b/src/InfoPanel.App/ViewModels/ProfileCardViewModel.cs index 9da735b..8ac70a9 100644 --- a/src/InfoPanel.App/ViewModels/ProfileCardViewModel.cs +++ b/src/InfoPanel.App/ViewModels/ProfileCardViewModel.cs @@ -105,6 +105,9 @@ public string Subtitle outputs.AddRange(host.Settings.JlPanelDevices .Where(d => d.Enabled && d.ProfileGuid == Profile.Guid) .Select(d => d.ModelInfo?.Name ?? d.Model.ToString())); + outputs.AddRange(host.Settings.ValkyriePanelDevices + .Where(d => d.Enabled && d.ProfileGuid == Profile.Guid) + .Select(d => d.ModelInfo?.Name ?? d.Model.ToString())); outputs.AddRange(host.Settings.VmaxPanelDevices .Where(d => d.Enabled && d.ProfileGuid == Profile.Guid) .Select(d => d.ModelInfo?.Name ?? d.Model.ToString())); diff --git a/src/InfoPanel.App/Views/Pages/DevicesPage.axaml.cs b/src/InfoPanel.App/Views/Pages/DevicesPage.axaml.cs index 41ff16f..17380e7 100644 --- a/src/InfoPanel.App/Views/Pages/DevicesPage.axaml.cs +++ b/src/InfoPanel.App/Views/Pages/DevicesPage.axaml.cs @@ -236,7 +236,7 @@ private void RebuildRows() AddRow( title: device.ModelInfo?.Name ?? device.Model.ToString(), subtitle: Subtitle(device.DeviceId, device.DeviceLocation, - device.DisplayWidth > 0 ? $"renders at {device.DisplayWidth}×{device.DisplayHeight}" : null), + device.DisplayWidth > 0 ? $"renders at {device.DisplayWidth}×{device.DisplayHeight}{(device.ModelInfo?.Experimental == true ? "; experimental, not hardware-tested on Linux" : "")}" : null), isEnabled: device.Enabled, setEnabled: v => { device.Enabled = v; _app.Host.SaveSettings(); }, profileGuid: device.ProfileGuid, @@ -250,7 +250,31 @@ private void RebuildRows() setRotation: r => { device.Rotation = r; _app.Host.SaveSettings(); }); AddPanelOptions(device.Brightness, b => { device.Brightness = b; _app.Host.SaveSettings(); }, device.TargetFrameRate, f => { device.TargetFrameRate = f; _app.Host.SaveSettings(); }, - device.JpegQuality, q => { device.JpegQuality = q; _app.Host.SaveSettings(); }); + null, null); + } + + AddFamilyHeader("Valkyrie", settings.ValkyriePanelMultiDeviceMode, + v => { settings.ValkyriePanelMultiDeviceMode = v; _app.Host.SaveSettings(); }); + foreach (var device in settings.ValkyriePanelDevices.ToList()) + { + AddRow( + title: device.ModelInfo?.Name ?? device.Model.ToString(), + subtitle: Subtitle(device.DeviceId, device.DeviceLocation, + device.DisplayWidth > 0 ? $"renders at {device.DisplayWidth}×{device.DisplayHeight}{(device.ModelInfo?.Experimental == true ? "; experimental, not hardware-tested on Linux" : "")}" : null), + isEnabled: device.Enabled, + setEnabled: v => { device.Enabled = v; _app.Host.SaveSettings(); }, + profileGuid: device.ProfileGuid, + setProfile: g => { device.ProfileGuid = g; _app.Host.SaveSettings(); }, + status: () => device.RuntimeProperties.IsRunning + ? RunningStatus(device.ProfileGuid, device.RuntimeProperties.FrameRate, device.RuntimeProperties.FrameTime) + : !device.Enabled ? "disabled" + : device.RuntimeProperties.ErrorMessage is { Length: > 0 } err ? err : "idle", + remove: () => { settings.ValkyriePanelDevices.Remove(device); _app.Host.SaveSettings(); RebuildRows(); }, + rotation: device.Rotation, + setRotation: r => { device.Rotation = r; _app.Host.SaveSettings(); }); + AddPanelOptions(device.Brightness, b => { device.Brightness = b; _app.Host.SaveSettings(); }, + device.TargetFrameRate, f => { device.TargetFrameRate = f; _app.Host.SaveSettings(); }, + null, null); } AddFamilyHeader("Jonsbo", settings.JonsboPanelMultiDeviceMode, @@ -339,6 +363,7 @@ private void AddHotkeysSection(Settings settings) foreach (var d in settings.ThermalrightPanelDevices) deviceChoices.Add(new("Thermalright", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); foreach (var d in settings.ThermaltakePanelDevices) deviceChoices.Add(new("Thermaltake", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); foreach (var d in settings.JlPanelDevices) deviceChoices.Add(new("Jl", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); + foreach (var d in settings.ValkyriePanelDevices) deviceChoices.Add(new("Valkyrie", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); foreach (var d in settings.VmaxPanelDevices) deviceChoices.Add(new("Vmax", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); foreach (var d in settings.JonsboPanelDevices) deviceChoices.Add(new("Jonsbo", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); foreach (var d in settings.LianLiPanelDevices) deviceChoices.Add(new("LianLi", d.DeviceId, d.ModelInfo?.Name ?? d.Model.ToString())); @@ -1002,62 +1027,13 @@ private async void Scan_Click(object? sender, RoutedEventArgs e) } } - // Jonsbo AIO (DS916 HLVMAX serial, DS339 MS9132) - var jonsbo = await Task.Run(JonsboPanelHelper.ScanDevices); - var ds339Confirmed = jonsbo.Any(f => f.Model == JonsboPanelModel.DS339 && f.Ms9132Confirmed); - var ms9132Ambiguous = jonsbo.Any(f => f.Model == JonsboPanelModel.DS339 && !f.Ms9132Confirmed); - foreach (var found in jonsbo) - { - var existing = settings.JonsboPanelDevices.FirstOrDefault(d => d.DeviceId == found.DeviceId); - if (existing == null) - { - // The MS9132 bridge is shared with the VMAX 4.6"; when the EDID - // probe could not confirm which panel is attached, don't shadow - // an existing VMAX configuration with a Jonsbo entry. - if (found.Model == JonsboPanelModel.DS339 && !found.Ms9132Confirmed - && settings.VmaxPanelDevices.Count > 0) - continue; - - settings.JonsboPanelDevices.Add(new JonsboPanelDevice - { - DeviceId = found.DeviceId, - DeviceLocation = found.DeviceLocation, - Model = found.Model, - }); - added++; - } - else - { - existing.DeviceLocation = found.DeviceLocation; - updated++; - } - } - - // VMAX / AuyiHomu - shares the MS9132 bridge (345F:9132) with the Jonsbo - // DS339, so skip when the EDID identified the panel as a DS339, or when - // it is unreadable but the device is already configured as a DS339. - var skipVmax = ds339Confirmed - || (ms9132Ambiguous && settings.JonsboPanelDevices.Any(d => d.Model == JonsboPanelModel.DS339)); - var vmax = skipVmax ? new List() : await Task.Run(VmaxPanelHelper.ScanDevices); - foreach (var found in vmax) - { - var existing = settings.VmaxPanelDevices.FirstOrDefault(d => d.DeviceId == found.DeviceId); - if (existing == null) - { - settings.VmaxPanelDevices.Add(new VmaxPanelDevice - { - DeviceId = found.DeviceId, - DeviceLocation = found.DeviceLocation, - Model = found.Model, - }); - added++; - } - else - { - existing.DeviceLocation = found.DeviceLocation; - updated++; - } - } + // Shared MS9132 bridges require a positive EDID identity. + var scanIssues = new List(); + var jonsbo = await Task.Run(() => JonsboPanelHelper.ScanDevices(scanIssues)); + var vmax = await Task.Run(() => VmaxPanelHelper.ScanDevices(scanIssues)); + var reconciled = InfoPanel.Services.PanelDiscoveryReconciler.Apply(settings, jonsbo, vmax); + added += reconciled.Added; + updated += reconciled.Updated; // Lian Li (encrypted bulk USB) foreach (var found in await LianLiPanelHelper.GetUsbDevices()) @@ -1103,7 +1079,8 @@ private async void Scan_Click(object? sender, RoutedEventArgs e) settings.LianLiPanelMultiDeviceMode = true; _app.Host.SaveSettings(); - ScanStatus.Text = $"Scan complete: {added} new, {updated} known device(s)."; + ScanStatus.Text = $"Scan complete: {added} new, {updated} known device(s)." + + (scanIssues.Count > 0 ? " " + string.Join(" ", scanIssues.Distinct()) : ""); RebuildRows(); } catch (Exception ex) diff --git a/src/InfoPanel.Core/Models/HotkeyBinding.cs b/src/InfoPanel.Core/Models/HotkeyBinding.cs index 8e86a80..9831b09 100644 --- a/src/InfoPanel.Core/Models/HotkeyBinding.cs +++ b/src/InfoPanel.Core/Models/HotkeyBinding.cs @@ -25,7 +25,7 @@ public partial class HotkeyBinding : ObservableObject [ObservableProperty] private string _key = "None"; - /// Device type: "Beada", "Turing", "Thermalright", "Thermaltake", "Jl", "Vmax", or "Jonsbo". + /// Device type: "Beada", "Turing", "Thermalright", "Thermaltake", "Jl", "Vmax", "Valkyrie", or "Jonsbo". [ObservableProperty] private string _deviceType = string.Empty; diff --git a/src/InfoPanel.Core/Models/Settings.cs b/src/InfoPanel.Core/Models/Settings.cs index 88f4386..4cbed62 100644 --- a/src/InfoPanel.Core/Models/Settings.cs +++ b/src/InfoPanel.Core/Models/Settings.cs @@ -112,6 +112,12 @@ public ObservableCollection VmaxPanelDevices [ObservableProperty] private bool _vmaxPanelMultiDeviceMode = false; + private readonly ObservableCollection _valkyriePanelDevices = []; + public ObservableCollection ValkyriePanelDevices => _valkyriePanelDevices; + + [ObservableProperty] + private bool _valkyriePanelMultiDeviceMode = false; + private readonly ObservableCollection _jonsboPanelDevices = []; public ObservableCollection JonsboPanelDevices @@ -191,6 +197,7 @@ public Settings() ThermaltakePanelDevices.CollectionChanged += ThermaltakePanelDevices_CollectionChanged; JlPanelDevices.CollectionChanged += JlPanelDevices_CollectionChanged; VmaxPanelDevices.CollectionChanged += VmaxPanelDevices_CollectionChanged; + ValkyriePanelDevices.CollectionChanged += ValkyriePanelDevices_CollectionChanged; JonsboPanelDevices.CollectionChanged += JonsboPanelDevices_CollectionChanged; LianLiPanelDevices.CollectionChanged += LianLiPanelDevices_CollectionChanged; } @@ -369,6 +376,35 @@ private void VmaxDevice_PropertyChanged(object? sender, System.ComponentModel.Pr } } + private void ValkyriePanelDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (VmaxPanelDevice device in e.OldItems) + { + device.PropertyChanged -= ValkyrieDevice_PropertyChanged; + } + } + + if (e.NewItems != null) + { + foreach (VmaxPanelDevice device in e.NewItems) + { + device.PropertyChanged += ValkyrieDevice_PropertyChanged; + } + } + + OnPropertyChanged(nameof(ValkyriePanelDevices)); + } + + private void ValkyrieDevice_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(VmaxPanelDevice.RuntimeProperties)) + { + OnPropertyChanged(nameof(ValkyriePanelDevices)); + } + } + private void LianLiPanelDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.OldItems != null) diff --git a/src/InfoPanel.Core/Models/ValkyriePanelSettings.cs b/src/InfoPanel.Core/Models/ValkyriePanelSettings.cs new file mode 100644 index 0000000..871d384 --- /dev/null +++ b/src/InfoPanel.Core/Models/ValkyriePanelSettings.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; + +namespace InfoPanel.Models; + +/// Separate retail families while sharing the LCD transport implementation. +public static class ValkyriePanelSettings +{ + public static IEnumerable EnabledDevices(Settings settings) => + settings.VmaxPanelDevices.Where(d => settings.VmaxPanelMultiDeviceMode && d.Enabled) + .Concat(settings.ValkyriePanelDevices.Where(d => settings.ValkyriePanelMultiDeviceMode && d.Enabled)); + + public static bool Migrate(Settings settings) + { + var old = settings.VmaxPanelDevices.Where(d => d.ModelInfo?.IsValkyrie == true).ToList(); + foreach (var device in old) + { + if (!settings.ValkyriePanelDevices.Any(d => d.DeviceId == device.DeviceId)) + settings.ValkyriePanelDevices.Add(device); + settings.VmaxPanelDevices.Remove(device); + foreach (var binding in settings.HotkeyBindings.Where(b => b.DeviceType == "Vmax" && b.DeviceId == device.DeviceId)) + binding.DeviceType = "Valkyrie"; + } + if (old.Count == 0) return false; + settings.ValkyriePanelMultiDeviceMode |= settings.VmaxPanelMultiDeviceMode; + if (settings.VmaxPanelDevices.Count == 0) settings.VmaxPanelMultiDeviceMode = false; + return true; + } +} diff --git a/src/InfoPanel.Core/VmaxPanel/VmaxPanelModel.cs b/src/InfoPanel.Core/VmaxPanel/VmaxPanelModel.cs index e530c93..e76ead2 100644 --- a/src/InfoPanel.Core/VmaxPanel/VmaxPanelModel.cs +++ b/src/InfoPanel.Core/VmaxPanel/VmaxPanelModel.cs @@ -4,5 +4,7 @@ public enum VmaxPanelModel { Unknown, Vmax46Inch, + ValkyrieV360, + ValkyrieMc360, } } diff --git a/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelDatabase.cs b/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelDatabase.cs index fedc9be..8d5539c 100644 --- a/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelDatabase.cs +++ b/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelDatabase.cs @@ -11,6 +11,7 @@ public static class VmaxPanelModelDatabase public static readonly (int Vid, int Pid)[] SupportedDevices = [ (VMAX_VENDOR_ID, VMAX_PRODUCT_ID_46INCH), + (0x374A, 0xA021), ]; public static readonly Dictionary Models = new() @@ -24,8 +25,28 @@ public static readonly (int Vid, int Pid)[] SupportedDevices = VendorId = VMAX_VENDOR_ID, ProductId = VMAX_PRODUCT_ID_46INCH, }, + [VmaxPanelModel.ValkyrieV360] = new VmaxPanelModelInfo + { + Model = VmaxPanelModel.ValkyrieV360, + Name = "Valkyrie V360 LCD", + Width = 320, + Height = 240, + VendorId = VMAX_VENDOR_ID, + ProductId = VMAX_PRODUCT_ID_46INCH, + }, + [VmaxPanelModel.ValkyrieMc360] = new VmaxPanelModelInfo + { + Model = VmaxPanelModel.ValkyrieMc360, + Name = "Valkyrie MC360 LCD", + Width = 640, Height = 480, VendorId = 0x374A, ProductId = 0xA021, + UsesBgr565 = true, Experimental = true, + }, }; + // VID/PID identifies the shared MS9132 bridge, not the attached panel. + public static VmaxPanelModelInfo? GetModelByPanelSize(int width, int height) => + Models.Values.FirstOrDefault(m => m.VendorId == VMAX_VENDOR_ID && m.Width == width && m.Height == height); + public static VmaxPanelModelInfo? GetModelByVidPid(int vid, int pid) { return Models.Values.FirstOrDefault(m => m.VendorId == vid && m.ProductId == pid); diff --git a/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelInfo.cs b/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelInfo.cs index dc006ec..2844558 100644 --- a/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelInfo.cs +++ b/src/InfoPanel.Core/VmaxPanel/VmaxPanelModelInfo.cs @@ -8,5 +8,8 @@ public class VmaxPanelModelInfo public int Height { get; init; } public int VendorId { get; init; } public int ProductId { get; init; } + public bool UsesBgr565 { get; init; } + public bool Experimental { get; init; } + public bool IsValkyrie => Model == VmaxPanelModel.ValkyrieV360 || Model == VmaxPanelModel.ValkyrieMc360; } } diff --git a/src/InfoPanel.Devices.JonsboPanel/JonsboPanelHelper.cs b/src/InfoPanel.Devices.JonsboPanel/JonsboPanelHelper.cs index 06fca98..3bb61a7 100644 --- a/src/InfoPanel.Devices.JonsboPanel/JonsboPanelHelper.cs +++ b/src/InfoPanel.Devices.JonsboPanel/JonsboPanelHelper.cs @@ -18,8 +18,7 @@ public class JonsboPanelDiscoveryInfo /// /// MS9132 entries only: true when the panel EDID confirmed DS339 geometry - /// (376x960); false when the EDID could not be read, in which case the device - /// might equally be a VMAX 4.6" (same MS9132 bridge, same VID/PID). + /// (376x960). Unreadable panels are omitted from discovery. /// public bool Ms9132Confirmed { get; set; } = true; } @@ -35,7 +34,7 @@ public static class JonsboPanelHelper { private static readonly ILogger Logger = Log.ForContext(typeof(JonsboPanelHelper)); - public static List ScanDevices() + public static List ScanDevices(List? issues = null) { var devices = new List(); @@ -70,21 +69,25 @@ public static List ScanDevices() // identity via the panel EDID. try { - var hid = DeviceList.Local.GetHidDevices( - JonsboPanelModelDatabase.MS9132_VENDOR_ID, JonsboPanelModelDatabase.MS9132_PRODUCT_ID) - .FirstOrDefault(); - if (hid != null) + foreach (var hid in DeviceList.Local.GetHidDevices( + JonsboPanelModelDatabase.MS9132_VENDOR_ID, JonsboPanelModelDatabase.MS9132_PRODUCT_ID)) { var modelInfo = JonsboPanelModelDatabase.Models[JonsboPanelModel.DS339]; var size = ProbeMs9132PanelSize(hid); - if (size is { } s && (s.Width != modelInfo.Width || s.Height != modelInfo.Height)) + if (size == null) + { + issues?.Add("MS9132 panel could not be identified. Check USB/HID permissions, then scan again."); + Logger.Warning("JonsboPanelHelper: Unreadable MS9132 EDID; skipping unidentified panel"); + continue; + } + if (!IsDs339(size)) { // A different MS9132-bridged panel (e.g. VMAX 4.6" at 320x960): // not ours, leave it to that family's scan. Logger.Information( "JonsboPanelHelper: MS9132 EDID reports {W}x{H}, not a DS339 - skipping", - s.Width, s.Height); + size.Value.Width, size.Value.Height); } else { @@ -117,6 +120,8 @@ public static List ScanDevices() return devices; } + public static bool IsDs339((int Width, int Height)? size) => size is (376, 960); + /// /// Reads the attached panel's native resolution from the first detailed timing /// descriptor of the EDID, which the MS9132 exposes one byte at a time at @@ -125,48 +130,10 @@ public static List ScanDevices() /// public static (int Width, int Height)? ProbeMs9132PanelSize(HidDevice? hid = null) { - try - { - hid ??= DeviceList.Local.GetHidDevices( - JonsboPanelModelDatabase.MS9132_VENDOR_ID, JonsboPanelModelDatabase.MS9132_PRODUCT_ID) - .FirstOrDefault(); - if (hid == null) return null; - var raw = LinuxHidRawFeature.Open(hid.DevicePath); - if (raw == null) return null; - - using (raw) - { - int ReadEdidByte(int offset) - { - ushort address = (ushort)(0xC000 + offset); - raw.SetFeature([0xB5, (byte)(address >> 8), (byte)(address & 0xFF), 0, 0, 0, 0, 0]); - return raw.GetFeature()[4]; - } - - // EDID header sanity: 00 FF FF FF FF FF FF 00. - if (ReadEdidByte(0) != 0x00 || ReadEdidByte(1) != 0xFF || ReadEdidByte(7) != 0x00) - { - Logger.Debug("JonsboPanelHelper: MS9132 EDID header mismatch"); - return null; - } - - // First detailed timing descriptor at offset 54. A zero pixel - // clock means no DTD is present. - if (ReadEdidByte(54) == 0 && ReadEdidByte(55) == 0) return null; - - int hactive = ReadEdidByte(56) | ((ReadEdidByte(58) & 0xF0) << 4); - int vactive = ReadEdidByte(59) | ((ReadEdidByte(61) & 0xF0) << 4); - Logger.Information("JonsboPanelHelper: MS9132 panel EDID reports {W}x{H}", hactive, vactive); - - if (hactive <= 0 || vactive <= 0 || hactive > 4096 || vactive > 4096) return null; - return (hactive, vactive); - } - } - catch (Exception ex) - { - Logger.Debug(ex, "JonsboPanelHelper: MS9132 EDID probe failed"); - return null; - } + hid ??= DeviceList.Local.GetHidDevices( + JonsboPanelModelDatabase.MS9132_VENDOR_ID, JonsboPanelModelDatabase.MS9132_PRODUCT_ID) + .FirstOrDefault(); + return hid == null ? null : Ms9132PanelProbe.ReadSize(hid.DevicePath); } /// Enumerates ttyACM ports and resolves the owning USB device's VID/PID via sysfs. diff --git a/src/InfoPanel.Devices.VmaxPanel/IValkyriePanelDevice.cs b/src/InfoPanel.Devices.VmaxPanel/IValkyriePanelDevice.cs new file mode 100644 index 0000000..ec7b95f --- /dev/null +++ b/src/InfoPanel.Devices.VmaxPanel/IValkyriePanelDevice.cs @@ -0,0 +1,8 @@ +namespace InfoPanel.VmaxPanel; + +internal interface IValkyriePanelDevice : IDisposable +{ + void SendFrame(byte[] pixels, int length); + void SetScreenSwitch(bool enabled); + void KeepAlive(); +} diff --git a/src/InfoPanel.Devices.VmaxPanel/ValkyrieUsbDevice.cs b/src/InfoPanel.Devices.VmaxPanel/ValkyrieUsbDevice.cs new file mode 100644 index 0000000..5ea760e --- /dev/null +++ b/src/InfoPanel.Devices.VmaxPanel/ValkyrieUsbDevice.cs @@ -0,0 +1,164 @@ +using LibUsbDotNet; +using LibUsbDotNet.Main; +using Serilog; +using System.Buffers.Binary; + +namespace InfoPanel.VmaxPanel; + +/// Experimental MC360 mode-2 transport; restricted to 374A:A021, 640x480. +internal sealed class ValkyrieUsbDevice : IValkyriePanelDevice +{ + private readonly UsbDevice _usb; + private readonly IUsbDevice _whole; + private UsbEndpointWriter? _writer; + private UsbEndpointReader? _reader; + private bool _claimed; + private bool _disposed; + private bool _enabled = true; + private bool _hasFrame; + private readonly byte[] _black = new byte[ValkyrieUsbProtocol.PixelDataLength]; + private DateTime _lastActivity = DateTime.MinValue; + private const int TimeoutMs = 2000; + + private ValkyrieUsbDevice(UsbDevice usb) + { + _usb = usb; + _whole = usb as IUsbDevice ?? throw new IOException("MC360 requires a libusb whole-device handle."); + } + + private static bool Matches(UsbRegistry entry) => entry.Vid == 0x374A && entry.Pid == 0xA021; + + private static string DeviceId(UsbRegistry entry, string serial) => string.IsNullOrEmpty(serial) + ? entry.DevicePath : $@"USB\VID_374A&PID_A021\{serial}"; + + public static List ScanDevices() + { + var found = new List(); + foreach (UsbRegistry entry in UsbDevice.AllDevices) + { + if (!Matches(entry)) continue; + try + { + using var device = OpenEntry(entry, null); + if (device == null) continue; + string serial = device._usb.Info.SerialString ?? ""; + var info = VmaxPanelModelDatabase.Models[VmaxPanelModel.ValkyrieMc360]; + found.Add(new VmaxPanelDiscoveryInfo + { + DeviceId = DeviceId(entry, serial), DeviceLocation = string.IsNullOrEmpty(serial) ? entry.DevicePath : serial, + DevicePath = entry.DevicePath, SerialNumber = serial, VendorId = entry.Vid, ProductId = entry.Pid, + Model = info.Model, ModelInfo = info, + }); + } + catch (Exception ex) + { + Log.Debug(ex, "MC360 probe failed for {Path}", entry.DevicePath); + } + } + return found; + } + + public static ValkyrieUsbDevice? Open(string deviceId) + { + foreach (UsbRegistry entry in UsbDevice.AllDevices) + { + if (!Matches(entry)) continue; + var device = OpenEntry(entry, deviceId); + if (device != null) return device; + } + return null; + } + + private static ValkyrieUsbDevice? OpenEntry(UsbRegistry entry, string? expectedId) + { + var usb = entry.Device; + if (usb == null) return null; + ValkyrieUsbDevice? device = null; + try + { + if (!ValkyrieUsbProtocol.IsMc360Descriptor(entry.Vid, entry.Pid, usb.Info.ProductString)) + { + usb.Close(); + return null; + } + if (expectedId != null && !string.Equals(expectedId, DeviceId(entry, usb.Info.SerialString ?? ""), StringComparison.Ordinal)) + { + usb.Close(); + return null; + } + device = new ValkyrieUsbDevice(usb); + device._claimed = device._whole.ClaimInterface(0); + if (!device._claimed) throw new IOException("Cannot claim MC360 USB interface 0; check udev permissions."); + device._writer = usb.OpenEndpointWriter(WriteEndpointID.Ep02); + device._reader = usb.OpenEndpointReader(ReadEndpointID.Ep01); + device.Status(0x1003, [0x45, 0x23, 0x01, 0x00]); + ValkyrieUsbProtocol.ValidateScreenInfo(device.Transact(0x0001, [], 28)); + // Do not set brightness or blank the screen while only probing it. + if (expectedId != null) device.Status(0x0002, [100]); + return device; + } + catch + { + if (device != null) device.Dispose(); else usb.Close(); + throw; + } + } + + private byte[] Transact(ushort command, byte[] payload, int replyLength = 4) + { + Write(ValkyrieUsbProtocol.Request(command, payload)); + byte[] response = new byte[6 + replyLength]; + var result = _reader!.Read(response, TimeoutMs, out int read); + if (result != ErrorCode.None) throw new IOException($"MC360 read failed: {result}."); + _lastActivity = DateTime.UtcNow; + return ValkyrieUsbProtocol.Reply(command, response.AsSpan(0, read), replyLength); + } + + private void Status(ushort command, byte[] payload) + { + var reply = Transact(command, payload); + if (BinaryPrimitives.ReadInt32LittleEndian(reply) != 0) + throw new IOException($"MC360 command {command:X4} failed: {Convert.ToHexString(reply)}."); + } + + private void Write(byte[] bytes, int offset = 0, int? count = null) + { + int length = count ?? bytes.Length; + var result = _writer!.Write(bytes, offset, length, TimeoutMs, out int sent); + if (result != ErrorCode.None || sent != length) + throw new IOException($"MC360 write failed: {result}, {sent}/{length} bytes."); + } + + public void SendFrame(byte[] pixels, int length) + { + ValkyrieUsbProtocol.WriteFrame(_enabled ? pixels : _black, length, + size => Status(0x1001, size), (buffer, offset, count) => Write(buffer, offset, count)); + _lastActivity = DateTime.UtcNow; + _hasFrame = true; + } + + public void SetScreenSwitch(bool enabled) + { + if (_enabled == enabled) return; + _enabled = enabled; + if (!enabled && _hasFrame) SendFrame(_black, _black.Length); + // Enabling invalidates the render cache in the task, sending real pixels. + } + + public void KeepAlive() + { + if ((DateTime.UtcNow - _lastActivity).TotalSeconds >= 1) + Status(0x000F, [0x78, 0x56, 0x34, 0x12]); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // Retain the last image, as the upstream V360 path does on exit. + _reader?.Dispose(); + _writer?.Dispose(); + if (_claimed) { try { _whole.ReleaseInterface(0); } catch { } } + _usb.Close(); + } +} diff --git a/src/InfoPanel.Devices.VmaxPanel/ValkyrieUsbProtocol.cs b/src/InfoPanel.Devices.VmaxPanel/ValkyrieUsbProtocol.cs new file mode 100644 index 0000000..2de3541 --- /dev/null +++ b/src/InfoPanel.Devices.VmaxPanel/ValkyrieUsbProtocol.cs @@ -0,0 +1,88 @@ +using System.Buffers.Binary; + +namespace InfoPanel.VmaxPanel; + +/// +/// MC360 / 374A:A021 mode-2 bulk protocol. Independently implemented from the +/// wire facts at https://github.com/codehz/msdisplay/blob/main/bowei_aio/display.py +/// MC360 identity and Windows validation: ZGMFX01A/MythCool-Lite README. +/// No proprietary driver or binaries are required. +/// +public static class ValkyrieUsbProtocol +{ + public const int Width = 640; + public const int Height = 480; + public const int PixelDataLength = Width * Height * 2; + + public static bool IsMc360Descriptor(int vendorId, int productId, string? productName) => + vendorId == 0x374A && productId == 0xA021 + && string.Equals(productName, "Vk-Copl-08", StringComparison.OrdinalIgnoreCase); + + public static byte[] Request(ushort command, ReadOnlySpan payload) + { + var packet = new byte[6 + payload.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(packet, command); + BinaryPrimitives.WriteUInt32LittleEndian(packet.AsSpan(2), (uint)payload.Length); + payload.CopyTo(packet.AsSpan(6)); + return packet; + } + + public static byte[] Reply(ushort command, ReadOnlySpan packet, int payloadLength) + { + if (packet.Length != 6 + payloadLength + || BinaryPrimitives.ReadUInt16LittleEndian(packet) != (command | 0x8000) + || BinaryPrimitives.ReadUInt32LittleEndian(packet[2..]) != payloadLength) + throw new IOException($"Invalid MC360 reply for command {command:X4}."); + return packet[6..].ToArray(); + } + + public static void ValidateScreenInfo(ReadOnlySpan info) + { + if (info.Length != 28 + || BinaryPrimitives.ReadUInt32LittleEndian(info[12..]) != Width + || BinaryPrimitives.ReadUInt32LittleEndian(info[16..]) != Height + || BinaryPrimitives.ReadUInt32LittleEndian(info[24..]) != PixelDataLength + || BinaryPrimitives.ReadUInt32LittleEndian(info) == 0xF001) + throw new IOException("MC360 requires an explicit 640x480 / 614400-byte BGR565 screen-info reply."); + } + + public static void WriteFrame(byte[] pixels, int length, Action announce, + Action write) + { + if (length != PixelDataLength || length > pixels.Length) + throw new ArgumentException("MC360 requires exactly 640x480 BGR565 pixels.", nameof(length)); + byte[] size = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(size, (uint)length); + announce(size); // The caller must validate command 0x1001's acknowledgement. + const int chunkSize = 0x20000; + for (int offset = 0; offset < length; offset += chunkSize) + { + int count = Math.Min(chunkSize, length - offset); + write(pixels, offset, count); + // Each aligned bulk transfer, rather than only the full frame, needs a ZLP. + if (count % 0x1000 == 0) write([], 0, 0); + } + } + + /// Encodes little-endian BGR565, respecting bitmap stride. + public static void ConvertRgba(ReadOnlySpan rgba, int width, int height, int stride, + Span output, int brightness) + { + if (width <= 0 || height <= 0 || stride < width * 4 + || rgba.Length < (height - 1) * stride + width * 4 || output.Length < width * height * 2) + throw new ArgumentException("Invalid MC360 pixel buffer."); + int scale = Math.Clamp(brightness, 0, 100); + int target = 0; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int source = y * stride + x * 4; + int r = rgba[source] * scale / 100, g = rgba[source + 1] * scale / 100, b = rgba[source + 2] * scale / 100; + ushort pixel = (ushort)(((b >> 3) << 11) | ((g >> 2) << 5) | (r >> 3)); + BinaryPrimitives.WriteUInt16LittleEndian(output[target..], pixel); + target += 2; + } + } + } +} diff --git a/src/InfoPanel.Devices.VmaxPanel/ValkyrieV360Protocol.cs b/src/InfoPanel.Devices.VmaxPanel/ValkyrieV360Protocol.cs new file mode 100644 index 0000000..9fa0b68 --- /dev/null +++ b/src/InfoPanel.Devices.VmaxPanel/ValkyrieV360Protocol.cs @@ -0,0 +1,125 @@ +// Protocol initialization adapted from itsmylife44/LcdFusion. +// https://github.com/itsmylife44/LcdFusion/blob/main/protocol/ValkyrieHidInitializer.cs +// The 77-command sequence was verified on a Valkyrie V360 under Fedora Linux. +// MIT License +// +// Copyright (c) 2026 itsmylife44 +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace InfoPanel.VmaxPanel; + +/// V360-specific initialization; other MS9132 panels must not use these timings. +public static class ValkyrieV360Protocol +{ + public const int Width = 320; + public const int Height = 240; + public const int PixelDataLength = Width * Height * 2; + public const int CommitEveryFrames = 30; + + public static void Initialize(Action command) + { + command([0xA6, 0x03, 0x03], false); + command([0xB5, 0xC5, 0x58], true); + command([0xB6, 0xDE, 0xEE, 0x01], false); + command([0xA6, 0x07, 0x01, 0x02], false); + command([0xB5, 0xC4, 0x54], true); + command([0xA6, 0x05, 0x00], false); + command([0xB5, 0xC5, 0x55], true); + command([0xB5, 0xF0, 0x04], true); + command([0xB6, 0xF0, 0x04, 0x6D], false); + command([0xF5, 0x00, 0x1F, 0xE0], true); + command([0xF5, 0x00, 0x1F, 0xE8], true); + command([0xF5, 0x00, 0x1F, 0xF0], true); + command([0xF5, 0x00, 0x1F, 0xF8], true); + command([0xB5, 0xC4, 0x24], true); + command([0xB5, 0xC6, 0x50], true); + command([0xB5, 0xC3, 0x0F], true); + command([0xB5, 0xFF, 0x00], true); + command([0xB5, 0xF0, 0x00], true); + command([0xB5, 0x00, 0x31], true); + command([0xB5, 0x00, 0x30], true); + command([0xB5, 0x00, 0x32], true); + command([0xB5, 0x00, 0x32], true); + command([0xB5, 0xC0, 0x00], true); + command([0xB5, 0xC0, 0x04], true); + command([0xB5, 0xC0, 0x08], true); + command([0xB5, 0xC0, 0x0C], true); + command([0xB5, 0xC0, 0x10], true); + command([0xB5, 0xC0, 0x14], true); + command([0xB5, 0xC0, 0x18], true); + command([0xB5, 0xC0, 0x1C], true); + command([0xB5, 0xC0, 0x20], true); + command([0xB5, 0xC0, 0x24], true); + command([0xB5, 0xC0, 0x28], true); + command([0xB5, 0xC0, 0x2C], true); + command([0xB5, 0xC0, 0x30], true); + command([0xB5, 0xC0, 0x34], true); + command([0xB5, 0xC0, 0x38], true); + command([0xB5, 0xC0, 0x3C], true); + command([0xB5, 0xC0, 0x40], true); + command([0xB5, 0xC0, 0x44], true); + command([0xB5, 0xC0, 0x48], true); + command([0xB5, 0xC0, 0x4C], true); + command([0xB5, 0xC0, 0x50], true); + command([0xB5, 0xC0, 0x54], true); + command([0xB5, 0xC0, 0x58], true); + command([0xB5, 0xC0, 0x5C], true); + command([0xB5, 0xC0, 0x60], true); + command([0xB5, 0xC0, 0x64], true); + command([0xB5, 0xC0, 0x68], true); + command([0xB5, 0xC0, 0x6C], true); + command([0xB5, 0xC0, 0x70], true); + command([0xB5, 0xC0, 0x74], true); + command([0xB5, 0xC0, 0x78], true); + command([0xB5, 0xC0, 0x7C], true); + command([0xF5, 0x00, 0x1F, 0xE0], true); + command([0xF5, 0x00, 0x1F, 0xE8], true); + command([0xF5, 0x00, 0x1F, 0xF0], true); + command([0xF5, 0x00, 0x1F, 0xF8], true); + command([0xB5, 0x00, 0x32], true); + command([0xA6, 0x05, 0x00], false); + command([0xB5, 0xC5, 0x55], true); + command([0xB5, 0xF5, 0x07], true); + command([0xB6, 0xF5, 0x07, 0x02], false); + command([0xA6, 0x04, 0x00], false); + command([0xB5, 0xC5, 0x55], true); + command([0xB5, 0x00, 0x32], true); + command([0xA6, 0x03, 0x03], false); + command([0xB5, 0xC5, 0x58], true); + command([0xA6, 0x01, 0x01, 0x40, 0x00, 0xF0, 0x22, 0x00], false); + command([0xB5, 0xC5, 0x55], true); + command([0xA6, 0x02, 0x94, 0x00, 0x01, 0x40, 0x00, 0xF0], false); + command([0xB5, 0xC5, 0x57], true); + command([0xA6, 0x04, 0x01, 0x00], false); + command([0xB5, 0xC5, 0x55], true); + command([0xB6, 0xF2, 0x4E, 0x00], false); + command([0xB5, 0x00, 0x32], true); + command([0xB5, 0xF2, 0x42, 0x00], true); + } + + // Start scan-out only after the first complete frame has been buffered. + public static void PostFrame(Action command) + { + command([0xB5, 0xF5, 0x07], true); + command([0xB6, 0xF5, 0x07, 0x00], false); + command([0xA6, 0x05, 0x01, 0x00], false); + command([0xB5, 0xC5, 0x55], true); + } +} diff --git a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelDeviceTask.cs b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelDeviceTask.cs index 06e1f4f..c20c252 100644 --- a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelDeviceTask.cs +++ b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelDeviceTask.cs @@ -25,6 +25,7 @@ public sealed class VmaxPanelDeviceTask : BackgroundTask public VmaxPanelDeviceTask(VmaxPanelDevice device) { _device = device; + _frameConsumer.ResendCachedOnSkip = device.Model == VmaxPanelModel.ValkyrieV360; } protected override async Task DoWorkAsync(CancellationToken token) @@ -49,7 +50,15 @@ protected override async Task DoWorkAsync(CancellationToken token) Logger.Information("VmaxPanelDevice {Device}: Opening USB device (attempt {Retry})", _device, retryCount + 1); - using var vmaxDevice = VmaxUsbDevice.Open(_device.DeviceId); + if (_device.Model == VmaxPanelModel.ValkyrieMc360) + { + using var mc360 = ValkyrieUsbDevice.Open(_device.DeviceId) + ?? throw new IOException("MC360 device not found."); + retryCount = 0; + await RunValkyrieLoop(mc360, token); + continue; + } + using var vmaxDevice = VmaxUsbDevice.Open(_device.DeviceId, _device.Model); if (vmaxDevice == null) { _device.UpdateRuntimeProperties(errorMessage: "Device not found"); @@ -59,7 +68,10 @@ protected override async Task DoWorkAsync(CancellationToken token) } retryCount = 0; - await RunRenderSendLoop(vmaxDevice, token); + if (modelInfo.IsValkyrie) + await RunValkyrieLoop(vmaxDevice, token); + else + await RunRenderSendLoop(vmaxDevice, token); } catch (OperationCanceledException) { @@ -81,6 +93,36 @@ protected override async Task DoWorkAsync(CancellationToken token) } } + private async Task RunValkyrieLoop(IValkyriePanelDevice device, CancellationToken token) + { + FpsCounter fpsCounter = new(30); + _device.UpdateRuntimeProperties(isRunning: true, errorMessage: string.Empty); + // One producer/sender owns the reusable pixel buffer. Only the tested + // V360 path resends static frames; experimental panels update on change. + (int Brightness, LCD_ROTATION Rotation, Guid Profile, bool Screen)? lastOptions = null; + while (!token.IsCancellationRequested) + { + var options = (_device.Brightness, _device.Rotation, _device.ProfileGuid, _device.ScreenSwitch); + if (lastOptions != options) + { + _frameConsumer.Invalidate(); + lastOptions = options; + } + var stopwatch = Stopwatch.StartNew(); + var frame = GenerateFrame(); + if (frame is { } ready) + { + device.SendFrame(ready.Buffer, ready.Length); + fpsCounter.Update(stopwatch.ElapsedMilliseconds); + _device.UpdateRuntimeProperties(frameRate: fpsCounter.FramesPerSecond, frameTime: fpsCounter.FrameTime); + } + device.SetScreenSwitch(_device.ScreenSwitch); + device.KeepAlive(); + var delay = 1000 / Math.Clamp(_device.TargetFrameRate, 1, 30) - (int)stopwatch.ElapsedMilliseconds; + if (delay > 0) await Task.Delay(delay, token); + } + } + private async Task RunRenderSendLoop(VmaxUsbDevice vmaxDevice, CancellationToken token) { using var renderCts = CancellationTokenSource.CreateLinkedTokenSource(token); @@ -98,7 +140,7 @@ private async Task RunRenderSendLoop(VmaxUsbDevice vmaxDevice, CancellationToken { while (!renderToken.IsCancellationRequested) { - var frame = GenerateUyvyFrame(); + var frame = GenerateFrame(); if (frame == null) { // Content unchanged: nothing to send; pace the loop. @@ -183,7 +225,7 @@ private static void ReturnFrame(RenderedFrame frame) private readonly SharedFrameConsumer _frameConsumer = new(); /// Returns null when the profile content has not changed (frame skipped). - private RenderedFrame? GenerateUyvyFrame() + private RenderedFrame? GenerateFrame() { if (DeviceRuntime.GetProfile(_device.ProfileGuid) is Profile profile) { @@ -194,9 +236,14 @@ private static void ReturnFrame(RenderedFrame frame) try { var length = resizedBitmap.Width * resizedBitmap.Height * 2; - var output = ArrayPool.Shared.Rent(length); - ToUyvy(resizedBitmap, output, _device.Brightness); - return (object)new RenderedFrame(output, length); + bool pooled = !_device.ModelInfo!.IsValkyrie; + var output = pooled ? ArrayPool.Shared.Rent(length) : (_rawPixels ??= new byte[length]); + if (!_device.ModelInfo.UsesBgr565) + ToUyvy(resizedBitmap, output, _device.Brightness); + else + ValkyrieUsbProtocol.ConvertRgba(resizedBitmap.GetPixelSpan(), resizedBitmap.Width, + resizedBitmap.Height, resizedBitmap.RowBytes, output, _device.Brightness); + return (object)new RenderedFrame(output, length, ReturnToPool: pooled); } finally { @@ -259,6 +306,7 @@ private static void ToUyvy(SKBitmap bitmap, byte[] output, int brightness) } private byte[]? _cachedBlackUyvy; + private byte[]? _rawPixels; private byte[] GenerateBlackUyvy() { @@ -266,7 +314,7 @@ private byte[] GenerateBlackUyvy() { // Black in UYVY is Y=16, U=V=128 - an all-zero buffer would be green. _cachedBlackUyvy = new byte[_panelWidth * _panelHeight * 2]; - for (int i = 0; i < _cachedBlackUyvy.Length; i += 2) + for (int i = 0; !_device.ModelInfo!.UsesBgr565 && i < _cachedBlackUyvy.Length; i += 2) { _cachedBlackUyvy[i] = 0x80; _cachedBlackUyvy[i + 1] = 0x10; diff --git a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelHelper.cs b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelHelper.cs index b25bb0c..c93d11d 100644 --- a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelHelper.cs +++ b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelHelper.cs @@ -15,6 +15,7 @@ public class VmaxPanelDiscoveryInfo public string DevicePath { get; set; } = ""; public int VendorId { get; set; } public int ProductId { get; set; } + public string SerialNumber { get; set; } = ""; public VmaxPanelModel Model { get; set; } public VmaxPanelModelInfo? ModelInfo { get; set; } } @@ -23,7 +24,7 @@ public static class VmaxPanelHelper { private static readonly ILogger Logger = Log.ForContext(typeof(VmaxPanelHelper)); - public static List ScanDevices() + public static List ScanDevices(List? issues = null) { var devices = new List(); @@ -31,7 +32,7 @@ public static List ScanDevices() { try { - devices.AddRange(ScanUsbDevices(vendorId, productId)); + devices.AddRange(ScanUsbDevices(vendorId, productId, issues)); } catch (Exception ex) { @@ -43,22 +44,50 @@ public static List ScanDevices() return devices; } - private static List ScanUsbDevices(int vendorId, int productId) + public static VmaxPanelModelInfo? ModelFromSize((int Width, int Height)? size) => + size is { } geometry + ? VmaxPanelModelDatabase.GetModelByPanelSize(geometry.Width, geometry.Height) + : null; + + private static List ScanUsbDevices(int vendorId, int productId, List? issues) { var devices = new List(); - var modelInfo = VmaxPanelModelDatabase.GetModelByVidPid(vendorId, productId); - if (modelInfo == null) return devices; + if (vendorId == 0x374A && productId == 0xA021) + return ValkyrieUsbDevice.ScanDevices(); + var hidDevices = DeviceList.Local.GetHidDevices(vendorId, productId).ToList(); foreach (UsbRegistry deviceReg in UsbDevice.AllDevices) { if (deviceReg.Vid != vendorId || deviceReg.Pid != productId) continue; + using var usb = deviceReg.Device; + if (usb == null) + { + issues?.Add("LCD USB device could not be opened. Check USB permissions, then scan again."); + Logger.Warning("VmaxPanelHelper: Cannot open USB device; check udev permissions"); + continue; + } + var serial = usb.Info.SerialString ?? ""; + var hid = hidDevices.FirstOrDefault(h => + !string.IsNullOrEmpty(serial) && h.GetSerialNumber() == serial); + // Match both interfaces of the same physical panel before probing. + var size = hid == null ? null : Ms9132PanelProbe.ReadSize(hid.DevicePath); + var modelInfo = ModelFromSize(size); + if (size == null) + issues?.Add("MS9132 panel could not be identified. Check USB/HID permissions, then scan again."); + if (modelInfo == null) continue; // e.g. Jonsbo DS339, 376x960 + deviceReg.DeviceProperties.TryGetValue("DeviceID", out var deviceIdValue); deviceReg.DeviceProperties.TryGetValue("LocationInformation", out var locationValue); var deviceId = deviceIdValue as string ?? deviceReg.DevicePath; var location = locationValue as string ?? deviceReg.SymbolicName ?? string.Empty; + if (modelInfo.Model == VmaxPanelModel.ValkyrieV360) + { + deviceId = $@"USB\VID_{vendorId:X4}&PID_{productId:X4}\{serial}"; + location = serial; + } Logger.Information("VmaxPanelHelper: Found {Model} at {DeviceId}", modelInfo.Name, deviceId); @@ -67,6 +96,7 @@ private static List ScanUsbDevices(int vendorId, int pro DeviceId = deviceId, DeviceLocation = location, DevicePath = deviceReg.DevicePath, + SerialNumber = serial, VendorId = vendorId, ProductId = productId, Model = modelInfo.Model, diff --git a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelTask.cs b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelTask.cs index 678ba0a..77bb751 100644 --- a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelTask.cs +++ b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelTask.cs @@ -74,7 +74,7 @@ protected override async Task DoWorkAsync(CancellationToken token) try { - if (DeviceRuntime.Settings.VmaxPanelMultiDeviceMode) + if (DeviceRuntime.Settings.VmaxPanelMultiDeviceMode || DeviceRuntime.Settings.ValkyriePanelMultiDeviceMode) await RunMultiDeviceMode(token); } catch (TaskCanceledException) { } @@ -91,9 +91,9 @@ private async Task RunMultiDeviceMode(CancellationToken token) try { var settings = DeviceRuntime.Settings; - if (!settings.VmaxPanelMultiDeviceMode) break; + if (!settings.VmaxPanelMultiDeviceMode && !settings.ValkyriePanelMultiDeviceMode) break; - var enabledConfigs = settings.VmaxPanelDevices.Where(d => d.Enabled).ToList(); + var enabledConfigs = ValkyriePanelSettings.EnabledDevices(settings).ToList(); foreach (var config in enabledConfigs) { diff --git a/src/InfoPanel.Devices.VmaxPanel/VmaxUsbDevice.cs b/src/InfoPanel.Devices.VmaxPanel/VmaxUsbDevice.cs index d836071..edbeb9d 100644 --- a/src/InfoPanel.Devices.VmaxPanel/VmaxUsbDevice.cs +++ b/src/InfoPanel.Devices.VmaxPanel/VmaxUsbDevice.cs @@ -8,7 +8,7 @@ namespace InfoPanel.VmaxPanel { - public sealed class VmaxUsbDevice : IDisposable + public sealed class VmaxUsbDevice : IDisposable, IValkyriePanelDevice { private static readonly ILogger Logger = Log.ForContext(); @@ -97,12 +97,15 @@ public sealed class VmaxUsbDevice : IDisposable private DateTime _lastStreamingStatusPolled = DateTime.MinValue; private byte[]? _frameBuffer; private bool _hasSentFrame; + private VmaxPanelModel _model; + private long _framesSent; + private static readonly byte[] V360FramePrefix = [0xFF, 0, 0, 0, 0, 0x14, 0, 0xF0]; private bool? _currentScreenSwitch; private bool _disposed; private VmaxUsbDevice() { } - public static VmaxUsbDevice? Open(string deviceId) + public static VmaxUsbDevice? Open(string deviceId, VmaxPanelModel model = VmaxPanelModel.Vmax46Inch) { foreach (UsbRegistry deviceReg in UsbDevice.AllDevices) { @@ -112,7 +115,7 @@ private VmaxUsbDevice() { } deviceReg.DeviceProperties.TryGetValue("DeviceID", out var registryDeviceIdValue); var registryDeviceId = registryDeviceIdValue as string; - if (!string.IsNullOrEmpty(deviceId) + if (model != VmaxPanelModel.ValkyrieV360 && !string.IsNullOrEmpty(deviceId) && !IsMatchingDevice(deviceId, registryDeviceId, deviceReg.DevicePath)) continue; @@ -123,24 +126,49 @@ private VmaxUsbDevice() { } continue; } - var device = new VmaxUsbDevice { _usbDevice = usbDevice }; + var device = new VmaxUsbDevice { _usbDevice = usbDevice, _model = model }; - if (usbDevice is IUsbDevice wholeUsbDevice) + try { - device._wholeUsbDevice = wholeUsbDevice; - var claimed = wholeUsbDevice.ClaimInterface(InterfaceNumber); - Logger.Information( - "VmaxUsbDevice: ClaimInterface({Interface})={Claimed}", - InterfaceNumber, - claimed); - } + if (model == VmaxPanelModel.ValkyrieV360 + && !string.Equals(deviceId, $@"USB\VID_345F&PID_9132\{usbDevice.Info.SerialString}", StringComparison.Ordinal)) + { + device.Dispose(); + continue; + } - device._hidRaw = InitializeStreamingMode(); + if (usbDevice is IUsbDevice wholeUsbDevice) + { + device._wholeUsbDevice = wholeUsbDevice; + var claimed = wholeUsbDevice.ClaimInterface(InterfaceNumber); + if (!claimed) + throw new IOException("Cannot claim MS9132 display interface 3: " + UsbDevice.LastErrorString); + Logger.Information( + "VmaxUsbDevice: ClaimInterface({Interface})={Claimed}", + InterfaceNumber, + claimed); + } - var writeEndpoint = FindWriteEndpoint(usbDevice); - device._writer = usbDevice.OpenEndpointWriter(writeEndpoint); - Logger.Information("VmaxUsbDevice: Opened {DeviceId} using endpoint 0x{Endpoint:X2}", registryDeviceId ?? deviceReg.DevicePath, (byte)writeEndpoint); - return device; + if (model == VmaxPanelModel.ValkyrieV360) + { + device._hidRaw = OpenValkyrieHid(usbDevice.Info.SerialString); + ValkyrieV360Protocol.Initialize(device.ValkyrieCommand); + } + else + { + device._hidRaw = InitializeStreamingMode(); + } + + var writeEndpoint = FindWriteEndpoint(usbDevice); + device._writer = usbDevice.OpenEndpointWriter(writeEndpoint); + Logger.Information("VmaxUsbDevice: Opened {DeviceId} using endpoint 0x{Endpoint:X2}", registryDeviceId ?? deviceReg.DevicePath, (byte)writeEndpoint); + return device; + } + catch + { + device.Dispose(); + throw; + } } Logger.Warning( @@ -150,6 +178,26 @@ private VmaxUsbDevice() { } return null; } + private static LinuxHidRawFeature OpenValkyrieHid(string serial) + { + // Match the HID and video handles by serial, not by the shared VID/PID. + // Never initialize another attached MS9132 screen by accident. + if (string.IsNullOrEmpty(serial)) + throw new IOException("V360 USB serial is unavailable; cannot safely match the HID interface."); + var hid = DeviceList.Local.GetHidDevices(0x345F, 0x9132) + .FirstOrDefault(h => string.Equals(h.GetSerialNumber(), serial, StringComparison.Ordinal)); + if (hid == null || Ms9132PanelProbe.ReadSize(hid.DevicePath) != (320, 240)) + throw new IOException("The selected USB device is not a confirmed 320x240 V360 panel."); + return LinuxHidRawFeature.Open(hid.DevicePath) + ?? throw new IOException("Cannot open V360 HID interface; check udev permissions."); + } + + private void ValkyrieCommand(byte[] payload, bool readReply) + { + _hidRaw!.SetFeature(payload); + if (readReply) _hidRaw.GetFeature(); + } + private static bool IsMatchingDevice(string requestedDeviceId, string? registryDeviceId, string? devicePath) { if (string.Equals(registryDeviceId, requestedDeviceId, StringComparison.OrdinalIgnoreCase) @@ -302,11 +350,14 @@ public void SendUyvyFrame(byte[] pixelData, int pixelDataLength) if (pixelDataLength < 0 || pixelDataLength > pixelData.Length) throw new ArgumentOutOfRangeException(nameof(pixelDataLength)); - var frameLength = FramePrefix.Length + pixelDataLength + FrameSuffix.Length; + var prefix = _model == VmaxPanelModel.ValkyrieV360 ? V360FramePrefix : FramePrefix; + if (_model == VmaxPanelModel.ValkyrieV360 && pixelDataLength != ValkyrieV360Protocol.PixelDataLength) + throw new ArgumentException("V360 requires exactly 320x240 UYVY pixels.", nameof(pixelDataLength)); + var frameLength = prefix.Length + pixelDataLength + FrameSuffix.Length; if (_frameBuffer == null || _frameBuffer.Length != frameLength) { _frameBuffer = new byte[frameLength]; - Buffer.BlockCopy(FramePrefix, 0, _frameBuffer, 0, FramePrefix.Length); + Buffer.BlockCopy(prefix, 0, _frameBuffer, 0, prefix.Length); Buffer.BlockCopy(FrameSuffix, 0, _frameBuffer, FramePrefix.Length + pixelDataLength, FrameSuffix.Length); } @@ -325,8 +376,19 @@ public void SendUyvyFrame(byte[] pixelData, int pixelDataLength) offset += bytesWritten; } + if (_model == VmaxPanelModel.ValkyrieV360) + { + if (!_hasSentFrame) + ValkyrieV360Protocol.PostFrame(ValkyrieCommand); + else if (_framesSent % ValkyrieV360Protocol.CommitEveryFrames == 0) + ValkyrieCommand([0xB5, 0x00, 0x32], true); + _framesSent++; + } + else + { + PollStreamingStatusIfDue(); + } _hasSentFrame = true; - PollStreamingStatusIfDue(); } public void SetScreenSwitch(bool enabled) @@ -336,6 +398,13 @@ public void SetScreenSwitch(bool enabled) try { + if (_model == VmaxPanelModel.ValkyrieV360) + { + // VMAX's B6 F0 05 backlight register is not verified on V360. + ValkyrieCommand([0xA6, 0x05, enabled ? (byte)1 : (byte)0], false); + _currentScreenSwitch = enabled; + return; + } byte[] response = []; foreach (var featureReport in GetScreenSwitchFeatureReports(enabled)) { @@ -385,6 +454,9 @@ private void PollStreamingStatusIfDue() } } + void IValkyriePanelDevice.SendFrame(byte[] pixels, int length) => SendUyvyFrame(pixels, length); + void IValkyriePanelDevice.KeepAlive() { } + public void Dispose() { if (_disposed) return; diff --git a/src/InfoPanel.Devices/Ms9132PanelProbe.cs b/src/InfoPanel.Devices/Ms9132PanelProbe.cs new file mode 100644 index 0000000..4523edf --- /dev/null +++ b/src/InfoPanel.Devices/Ms9132PanelProbe.cs @@ -0,0 +1,52 @@ +using Serilog; +using System; + +namespace InfoPanel; + +/// Reads EDID geometry without programming or enabling the shared MS9132 bridge. +public static class Ms9132PanelProbe +{ + private static readonly ILogger Logger = Log.ForContext(typeof(Ms9132PanelProbe)); + + public static (int Width, int Height)? ReadSize(string hidPath) + { + try + { + var raw = LinuxHidRawFeature.Open(hidPath); + if (raw == null) return null; + + using (raw) + { + int ReadEdidByte(int offset) + { + ushort address = (ushort)(0xC000 + offset); + raw.SetFeature([0xB5, (byte)(address >> 8), (byte)(address & 0xFF), 0, 0, 0, 0, 0]); + return raw.GetFeature()[4]; + } + + // EDID header sanity: 00 FF FF FF FF FF FF 00. + if (ReadEdidByte(0) != 0x00 || ReadEdidByte(1) != 0xFF || ReadEdidByte(7) != 0x00) + { + Logger.Debug("Ms9132PanelProbe: MS9132 EDID header mismatch"); + return null; + } + + // First detailed timing descriptor at offset 54. A zero pixel + // clock means no DTD is present. + if (ReadEdidByte(54) == 0 && ReadEdidByte(55) == 0) return null; + + int hactive = ReadEdidByte(56) | ((ReadEdidByte(58) & 0xF0) << 4); + int vactive = ReadEdidByte(59) | ((ReadEdidByte(61) & 0xF0) << 4); + Logger.Information("Ms9132PanelProbe: MS9132 panel EDID reports {W}x{H}", hactive, vactive); + + if (hactive <= 0 || vactive <= 0 || hactive > 4096 || vactive > 4096) return null; + return (hactive, vactive); + } + } + catch (Exception ex) + { + Logger.Debug(ex, "Ms9132PanelProbe: MS9132 EDID probe failed"); + return null; + } + } +} diff --git a/tests/InfoPanel.App.Tests/PickerLayoutTests.cs b/tests/InfoPanel.App.Tests/PickerLayoutTests.cs index e11a217..9913f5c 100644 --- a/tests/InfoPanel.App.Tests/PickerLayoutTests.cs +++ b/tests/InfoPanel.App.Tests/PickerLayoutTests.cs @@ -16,6 +16,35 @@ public sealed class PickerLayoutTests(HeadlessUi ui, ITestOutputHelper output) : { private const string DiskName = "WD_BLACK SN850X 4000GB (…0995, nvme0n1) Read Latency"; + [Fact] + public Task ValkyrieDevicesHaveTheirOwnSection() => ui.Run(() => + { + var host = new global::InfoPanel.AppHost(); + host.Settings.ValkyriePanelDevices.Add(new VmaxPanelDevice + { + DeviceId = "test-v360", Model = global::InfoPanel.VmaxPanel.VmaxPanelModel.ValkyrieV360, + }); + var appType = typeof(global::InfoPanel.AppHost).Assembly.GetType("InfoPanel.App")!; + var app = Activator.CreateInstance(appType)!; + appType.GetField("_host", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(app, host); + var page = new DevicesPage(); + typeof(DevicesPage).GetField("_app", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(page, app); + typeof(DevicesPage).GetMethod("RebuildRows", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(page, null); + using var root = HeadlessUi.CreateRoot(); + root.Content = page; + root.Prepare(); + root.Measure(new Size(1300, 1200)); + root.Arrange(new Rect(0, 0, 1300, 1200)); + root.UpdateLayout(); + var texts = page.GetVisualDescendants().OfType().Select(t => t.Text).ToList(); + var vmax = texts.IndexOf("VMAX / AuyiHomu"); + var vk = texts.IndexOf("Valkyrie"); + var model = texts.IndexOf("Valkyrie V360 LCD"); + var jonsbo = texts.IndexOf("Jonsbo"); + Assert.True(vmax >= 0 && vk > vmax && model > vk && jonsbo > model, string.Join(" | ", texts)); + Assert.DoesNotContain("VMAX / AuyiHomu / Valkyrie", texts); + }); + [Fact] public Task DesignerSplitterMinimumLeavesRoomForIndentedValues() => ui.Run(() => { diff --git a/tests/InfoPanel.App.Tests/ValkyrieDiscoveryTests.cs b/tests/InfoPanel.App.Tests/ValkyrieDiscoveryTests.cs new file mode 100644 index 0000000..dc932a7 --- /dev/null +++ b/tests/InfoPanel.App.Tests/ValkyrieDiscoveryTests.cs @@ -0,0 +1,126 @@ +using System.Xml.Serialization; +using InfoPanel.JonsboPanel; +using InfoPanel.Models; +using InfoPanel.Services; +using InfoPanel.VmaxPanel; +using Xunit; + +namespace InfoPanel.App.Tests; + +public class ValkyrieDiscoveryTests +{ + private const string DeviceId = @"USB\VID_345F&PID_9132\test-panel"; + private static VmaxPanelDiscoveryInfo V360() => new() + { + DeviceId = DeviceId, DeviceLocation = "test-panel", SerialNumber = "test-panel", + Model = VmaxPanelModel.ValkyrieV360, + ModelInfo = VmaxPanelModelDatabase.GetModelByPanelSize(320, 240), + }; + + [Fact] + public void UnreadableBridgeIsNotAssignedARetailModel() + { + Assert.False(JonsboPanelHelper.IsDs339(null)); + Assert.Null(VmaxPanelHelper.ModelFromSize(null)); + Assert.True(JonsboPanelHelper.IsDs339((376, 960))); + Assert.False(JonsboPanelHelper.IsDs339((320, 240))); + Assert.Null(VmaxPanelHelper.ModelFromSize((480, 480))); + } + + [Fact] + public void ConfirmedV360ReplacesOnlyMatchingMistakenJonsboEntry() + { + var profile = Guid.NewGuid(); + var settings = new Settings { JonsboPanelMultiDeviceMode = true }; + settings.JonsboPanelDevices.Add(new JonsboPanelDevice + { + DeviceId = DeviceId, Model = JonsboPanelModel.DS339, + ProfileGuid = profile, Enabled = false, + }); + var unrelated = new JonsboPanelDevice { DeviceId = "other", Model = JonsboPanelModel.DS339 }; + settings.JonsboPanelDevices.Add(unrelated); + settings.HotkeyBindings.Add(new HotkeyBinding { DeviceId = DeviceId, DeviceType = "Jonsbo", ProfileGuid = profile }); + var result = PanelDiscoveryReconciler.Apply(settings, [], [V360()]); + Assert.Equal((1, 0), result); + Assert.Same(unrelated, Assert.Single(settings.JonsboPanelDevices)); + Assert.Empty(settings.VmaxPanelDevices); + var device = Assert.Single(settings.ValkyriePanelDevices); + Assert.Equal(profile, device.ProfileGuid); + Assert.False(device.Enabled); + Assert.Equal(LCD_ROTATION.RotateNone, device.Rotation); + Assert.True(settings.ValkyriePanelMultiDeviceMode); + Assert.Equal("Valkyrie", settings.HotkeyBindings[0].DeviceType); + Assert.Equal((0, 1), PanelDiscoveryReconciler.Apply(settings, [], [V360()])); + Assert.Single(settings.ValkyriePanelDevices); + } + + [Fact] + public void UnknownScanDoesNotDeleteSavedConfigurationsOrGuessModel() + { + var settings = new Settings(); + var old = new JonsboPanelDevice { DeviceId = DeviceId, Model = JonsboPanelModel.DS339 }; + settings.JonsboPanelDevices.Add(old); + PanelDiscoveryReconciler.Apply(settings, + [new JonsboPanelDiscoveryInfo { DeviceId = DeviceId, Model = JonsboPanelModel.DS339, Ms9132Confirmed = false }], []); + Assert.Same(old, Assert.Single(settings.JonsboPanelDevices)); + Assert.Empty(settings.ValkyriePanelDevices); + Assert.Empty(settings.VmaxPanelDevices); + } + + [Fact] + public void LegacyValkyrieConfigurationAndHotkeysMoveWithoutLosingSettings() + { + var settings = new Settings { VmaxPanelMultiDeviceMode = true }; + var device = new VmaxPanelDevice { DeviceId = DeviceId, Model = VmaxPanelModel.ValkyrieV360, Enabled = true, ProfileGuid = Guid.NewGuid(), Brightness = 42 }; + var vmax = new VmaxPanelDevice { DeviceId = "vmax", Model = VmaxPanelModel.Vmax46Inch }; + settings.VmaxPanelDevices.Add(device); + settings.VmaxPanelDevices.Add(vmax); + settings.HotkeyBindings.Add(new HotkeyBinding { DeviceId = DeviceId, DeviceType = "Vmax" }); + Assert.True(ValkyriePanelSettings.Migrate(settings)); + Assert.Same(device, Assert.Single(settings.ValkyriePanelDevices)); + Assert.Same(vmax, Assert.Single(settings.VmaxPanelDevices)); + Assert.Equal(42, device.Brightness); + Assert.True(settings.ValkyriePanelMultiDeviceMode); + Assert.True(settings.VmaxPanelMultiDeviceMode); + Assert.Equal("Valkyrie", settings.HotkeyBindings[0].DeviceType); + Assert.False(ValkyriePanelSettings.Migrate(settings)); + } + + [Theory] + [InlineData(false, false, 0)] + [InlineData(true, false, 1)] + [InlineData(false, true, 1)] + [InlineData(true, true, 2)] + public void FamilyMasterSwitchesGateStreamingIndependently(bool vmax, bool vk, int count) + { + var settings = new Settings { VmaxPanelMultiDeviceMode = vmax, ValkyriePanelMultiDeviceMode = vk }; + settings.VmaxPanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.Vmax46Inch, Enabled = true }); + settings.ValkyriePanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieV360, Enabled = true }); + settings.ValkyriePanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieMc360, Enabled = false }); + var enabled = ValkyriePanelSettings.EnabledDevices(settings).ToList(); + Assert.Equal(count, enabled.Count); + Assert.Equal(vmax, enabled.Any(d => d.Model == VmaxPanelModel.Vmax46Inch)); + Assert.Equal(vk, enabled.Any(d => d.Model == VmaxPanelModel.ValkyrieV360)); + } + + [Fact] + public void SeparateFamilySettingsRoundTripAndNotifyChanges() + { + var settings = new Settings { ValkyriePanelMultiDeviceMode = true }; + settings.ValkyriePanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieV360 }); + settings.ValkyriePanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieMc360 }); + var serializer = new XmlSerializer(typeof(Settings)); + using var writer = new StringWriter(); + serializer.Serialize(writer, settings); + using var reader = new StringReader(writer.ToString()); + var restored = Assert.IsType(serializer.Deserialize(reader)); + Assert.True(restored.ValkyriePanelMultiDeviceMode); + Assert.False(restored.VmaxPanelMultiDeviceMode); + Assert.Equal(2, restored.ValkyriePanelDevices.Count); + Assert.Empty(restored.VmaxPanelDevices); + var notifications = new List(); + restored.PropertyChanged += (_, e) => notifications.Add(e.PropertyName); + restored.ValkyriePanelDevices[0].Brightness = 42; + Assert.Contains(nameof(Settings.ValkyriePanelDevices), notifications); + } +} diff --git a/tests/InfoPanel.App.Tests/ValkyrieMc360Tests.cs b/tests/InfoPanel.App.Tests/ValkyrieMc360Tests.cs new file mode 100644 index 0000000..318eff0 --- /dev/null +++ b/tests/InfoPanel.App.Tests/ValkyrieMc360Tests.cs @@ -0,0 +1,121 @@ +using System.Buffers.Binary; +using System.Xml.Serialization; +using InfoPanel.Models; +using InfoPanel.VmaxPanel; +using Xunit; + +namespace InfoPanel.App.Tests; + +public class ValkyrieMc360Tests +{ + [Fact] + public void Mc360IsNotAnMs9132GeometryAlias() + { + Assert.Null(VmaxPanelModelDatabase.GetModelByPanelSize(640, 480)); + var model = VmaxPanelModelDatabase.GetModelByVidPid(0x374A, 0xA021)!; + Assert.Equal(VmaxPanelModel.ValkyrieMc360, model.Model); + Assert.Equal("Valkyrie MC360 LCD", model.Name); + Assert.True(model.UsesBgr565); + Assert.True(model.Experimental); + Assert.Equal(640, model.Width); + Assert.Equal(480, model.Height); + } + + [Theory] + [InlineData(0x374A, 0xA021, "Vk-Copl-08", true)] + [InlineData(0x374A, 0xA021, "another screen", false)] + [InlineData(0x374A, 0xA001, "Vk-Copl-08", false)] + [InlineData(0x345F, 0x9132, "Vk-Copl-08", false)] + public void RequiresTheKnownMc360UsbIdentity(int vid, int pid, string name, bool expected) + { + Assert.Equal(expected, ValkyrieUsbProtocol.IsMc360Descriptor(vid, pid, name)); + } + + [Fact] + public void SerializesMc360WithoutChangingV360() + { + var serializer = new XmlSerializer(typeof(VmaxPanelDevice)); + using var text = new StringWriter(); + serializer.Serialize(text, new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieMc360 }); + using var input = new StringReader(text.ToString()); + var device = Assert.IsType(serializer.Deserialize(input)); + Assert.Equal(VmaxPanelModel.ValkyrieMc360, device.Model); + Assert.Equal(640, device.DisplayWidth); + Assert.Equal(240, new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieV360 }.DisplayHeight); + } + + [Fact] + public void UsesModeTwoLittleEndianCommands() + { + Assert.Equal("03100400000045230100", Convert.ToHexString(ValkyrieUsbProtocol.Request(0x1003, [0x45, 0x23, 1, 0]))); + Assert.Equal("01100400000000600900", Convert.ToHexString(ValkyrieUsbProtocol.Request(0x1001, [0, 0x60, 9, 0]))); + Assert.Equal(new byte[4], ValkyrieUsbProtocol.Reply(0x1003, [3, 0x90, 4, 0, 0, 0, 0, 0, 0, 0], 4)); + } + + [Theory] + [InlineData("039004000000000000")] // truncated status + [InlineData("01900400000000000000")] // another command's response + [InlineData("03900300000000000000")] // incorrect declared size + [InlineData("0390040000000000000000")] // trailing payload + public void RejectsMalformedReply(string hex) + { + Assert.Throws(() => ValkyrieUsbProtocol.Reply(0x1003, Convert.FromHexString(hex), 4)); + } + + private static byte[] ScreenInfo(uint width = 640, uint height = 480, uint size = 614400, uint format = 0) + { + var bytes = new byte[28]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, format); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12), width); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), height); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24), size); + return bytes; + } + + [Fact] + public void RequiresExplicitScreenInfoWithoutGuessingFromBufferSize() + { + ValkyrieUsbProtocol.ValidateScreenInfo(ScreenInfo()); + Assert.Throws(() => ValkyrieUsbProtocol.ValidateScreenInfo(ScreenInfo(width: 480, height: 640))); + Assert.Throws(() => ValkyrieUsbProtocol.ValidateScreenInfo(ScreenInfo(size: 153600))); + Assert.Throws(() => ValkyrieUsbProtocol.ValidateScreenInfo(ScreenInfo(format: 0xF001))); + Assert.Throws(() => ValkyrieUsbProtocol.ValidateScreenInfo(new byte[27])); + } + + [Fact] + public void ConvertsPrimaryColorsAndRespectsRowStride() + { + // BGR565 on the wire, not the RGB565 channel ordering of many other panels. + byte[] rgba = [255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9, + 0, 0, 255, 255, 255, 255, 255, 255]; + byte[] pixels = new byte[8]; + ValkyrieUsbProtocol.ConvertRgba(rgba, 2, 2, 12, pixels, 100); + Assert.Equal("1F00E00700F8FFFF", Convert.ToHexString(pixels)); + ValkyrieUsbProtocol.ConvertRgba(rgba, 2, 2, 12, pixels, 0); + Assert.Equal(new byte[8], pixels); + } + + [Fact] + public void AnnouncesSizeBeforeDataAndTerminatesEveryAlignedChunk() + { + var calls = new List(); + var pixels = new byte[ValkyrieUsbProtocol.PixelDataLength]; + ValkyrieUsbProtocol.WriteFrame(pixels, pixels.Length, + size => calls.Add("size:" + Convert.ToHexString(size)), + (buffer, offset, count) => calls.Add($"{offset}:{count}")); + Assert.Equal(new[] { "size:00600900", "0:131072", "0:0", "131072:131072", "0:0", + "262144:131072", "0:0", "393216:131072", "0:0", "524288:90112", "0:0" }, calls); + } + + [Fact] + public void DoesNotTransferDataWhenSizeAcknowledgementFails() + { + bool wrote = false; + Assert.Throws(() => ValkyrieUsbProtocol.WriteFrame(new byte[614400], 614400, + _ => throw new IOException("Rejected"), (_, _, _) => wrote = true)); + Assert.False(wrote); + Assert.Throws(() => ValkyrieUsbProtocol.WriteFrame(new byte[1], 614400, + _ => wrote = true, (_, _, _) => wrote = true)); + Assert.False(wrote); + } +} diff --git a/tests/InfoPanel.App.Tests/ValkyrieV360Tests.cs b/tests/InfoPanel.App.Tests/ValkyrieV360Tests.cs new file mode 100644 index 0000000..3795568 --- /dev/null +++ b/tests/InfoPanel.App.Tests/ValkyrieV360Tests.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Xml.Serialization; +using InfoPanel.Models; +using InfoPanel.VmaxPanel; +using Xunit; + +namespace InfoPanel.App.Tests; + +public class ValkyrieV360Tests +{ + [Theory] + [InlineData(320, 240, VmaxPanelModel.ValkyrieV360)] + [InlineData(320, 960, VmaxPanelModel.Vmax46Inch)] + [InlineData(376, 960, VmaxPanelModel.Unknown)] // Jonsbo DS339 shares the VID/PID + [InlineData(480, 480, VmaxPanelModel.Unknown)] + public void UsesEdidGeometryInsteadOfSharedUsbId(int width, int height, VmaxPanelModel expected) + { + Assert.Equal(expected, + VmaxPanelModelDatabase.GetModelByPanelSize(width, height)?.Model ?? VmaxPanelModel.Unknown); + } + + [Fact] + public void InitializationMatchesHardwareValidatedTrace() + { + var commands = new List(); + ValkyrieV360Protocol.Initialize((payload, readReply) => + { + // Fixture digest from the independently hardware-tested Linux script: + // [read-reply flag][8-byte padded feature payload] for all 77 commands. + var entry = new byte[9]; + entry[0] = readReply ? (byte)1 : (byte)0; + payload.CopyTo(entry, 1); + commands.Add(entry); + }); + Assert.Equal(77, commands.Count); + Assert.Equal("4F2310227A5313251AA18B5EF6333B0967E2669A36F566AA064F36F99C57F8A2", + Convert.ToHexString(SHA256.HashData(commands.SelectMany(c => c).ToArray()))); + } + + [Fact] + public void EnablesScanOutOnlyThroughPostFrameSequence() + { + var commands = new List(); + ValkyrieV360Protocol.PostFrame((payload, read) => + commands.Add($"{Convert.ToHexString(payload)}:{read}")); + Assert.Equal(new[] { "B5F507:True", "B6F50700:False", "A6050100:False", "B5C555:True" }, commands); + } + + [Fact] + public void PreservesBothModelsWhenSettingsRoundTrip() + { + var settings = new Settings { VmaxPanelMultiDeviceMode = true }; + settings.VmaxPanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.Vmax46Inch }); + settings.VmaxPanelDevices.Add(new VmaxPanelDevice { Model = VmaxPanelModel.ValkyrieV360 }); + var serializer = new XmlSerializer(typeof(Settings)); + using var writer = new StringWriter(); + serializer.Serialize(writer, settings); + using var reader = new StringReader(writer.ToString()); + var restored = Assert.IsType(serializer.Deserialize(reader)); + Assert.True(restored.VmaxPanelMultiDeviceMode); + Assert.Equal(new[] { VmaxPanelModel.Vmax46Inch, VmaxPanelModel.ValkyrieV360 }, + restored.VmaxPanelDevices.Select(d => d.Model)); + Assert.Equal(320, restored.VmaxPanelDevices[1].DisplayWidth); + Assert.Equal(240, restored.VmaxPanelDevices[1].DisplayHeight); + Assert.Equal(960, restored.VmaxPanelDevices[0].DisplayHeight); + } +}