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);
+ }
+}