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..02a1828 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` |
+
+Protocol details and test status are in [docs/VALKYRIE-V360.md](docs/VALKYRIE-V360.md)
+and [docs/VALKYRIE-MC360.md](docs/VALKYRIE-MC360.md). Only identified retail
+models are listed; a matching screen size alone doesn't make a panel supported.
+
### 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
@@ -575,6 +586,8 @@ rendering backend for diagnosing platform issues.
(Universal Screen and HydroShift II models).
- [yattuLizard](https://github.com/yattuLizard): VMAX / AuyiHomu panel
support.
+- [EsiksonX](https://github.com/EsiksonX): Valkyrie V360 and MC360 panel
+ support.
- [fweepa](https://github.com/fweepa): stopwatch plugin and hotkeys.
- [Orkunowski](https://github.com/Orkunowski): designer UX improvements.
- [LACT](https://github.com/ilya-zlobintsev/LACT): the NvAPI-on-Linux
diff --git a/aur/infopanel-bin.install b/aur/infopanel-bin.install
index 1d02805..a80813f 100644
--- a/aur/infopanel-bin.install
+++ b/aur/infopanel-bin.install
@@ -1,4 +1,24 @@
+_trigger_lcds() {
+ [ -S /run/udev/control ] || return 0
+ 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 || :
+}
+
post_install() {
+ _trigger_lcds
cat <<'EOF'
==> InfoPanel setup:
USB panels: replug the panel once (or run: udevadm trigger) so the
@@ -14,5 +34,5 @@ EOF
}
post_upgrade() {
- return 0
+ _trigger_lcds
}
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 12120e0..f0ca3f3 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -134,7 +134,7 @@ To keep idle cost low, a plugin whose sensors are not shown on any streaming pan
## Devices
-The Devices page detects supported USB LCD panels: BeadaPanel, Turing Smart Screen (including Rev 4.6"), Thermalright (40+ models), Thermaltake / ASRock, JL / Hongtai, VMAX, Jonsbo (DS916, DS339) and Lian Li (Universal Screen 8.8"/9.2", HydroShift II). For each panel you can assign a profile, set rotation and brightness, and watch the live frame rate and latency. To stream, a panel needs its row's Enabled switch on and a profile assigned; each family also has a streaming master switch in its section header, which turns on automatically when a scan finds the family's first panel.
+The Devices page detects supported USB LCD panels: BeadaPanel, Turing Smart Screen (including Rev 4.6"), Thermalright (40+ models), Thermaltake / ASRock, JL / Hongtai, VMAX, Valkyrie (V360, MC360 experimental), Jonsbo (DS916, DS339) and Lian Li (Universal Screen 8.8"/9.2", HydroShift II). For each panel you can assign a profile, set rotation and brightness, and watch the live frame rate and latency. To stream, a panel needs its row's Enabled switch on and a profile assigned; each family also has a streaming master switch in its section header, which turns on automatically when a scan finds the family's first panel.
Some panels share one USB ID across many models (for example most Thermalright HID panels). The scan identifies the exact model by briefly talking to the panel; if that is not possible the row shows a placeholder model until the first connection, when the panel reports what it really is.
diff --git a/docs/VALKYRIE-MC360.md b/docs/VALKYRIE-MC360.md
new file mode 100644
index 0000000..8165b1f
--- /dev/null
+++ b/docs/VALKYRIE-MC360.md
@@ -0,0 +1,63 @@
+# Valkyrie MC360 LCD (640×480, experimental)
+
+This driver has not run on a physical MC360 yet. The device row on the Devices
+page says so. It shares nothing with the V360 HID/UYVY path.
+
+## Identification
+
+A device has to pass all of these checks before it's added or started:
+
+- USB `374A:A021` with product string `Vk-Copl-08` (case-insensitive).
+- A valid mode-2 reply to the open handshake and the screen-info command.
+- Screen info reporting width 640, height 480 and a 614400-byte framebuffer.
+ The `F001` pixel-format variant is rejected.
+
+The serial, when there is one, selects the device. Otherwise the USB device path
+does. Geometry is queried again on every connection. Other BoWei product IDs,
+product strings or geometries never get the MC360 name. A scan only handshakes
+and reads screen info; it doesn't set brightness or send an image. The
+`374a:a021` USB udev rule is enough, since there is no HID interface.
+
+## Wire protocol
+
+Interface 0 uses bulk OUT `02` and bulk IN `81`. A request is a little-endian
+16-bit command followed by a 32-bit payload length. A reply echoes the command
+ORed with `8000`, and its declared length must match what arrived. A nonzero
+four-byte status reply fails the operation.
+
+Opening sends command `1003` with payload `45 23 01 00`, then command `0001` for
+the 28-byte screen-info structure. Width, height and framebuffer length sit at
+byte offsets 12, 16 and 24. A streaming connection sets brightness (`0002`) to
+100, and the profile's brightness setting dims the pixels in software.
+
+Frames are little-endian BGR565. Command `1001` announces the four-byte byte
+count, and raw pixels follow only after it's acknowledged, in chunks of up to
+`0x20000` bytes. Every transfer that is a multiple of `0x1000` bytes is followed
+by a zero-length packet. Static profiles are sent when their content or options
+change, and idle connections send `000F` with `78 56 34 12` once a second.
+Screen-off sends a black frame. On exit the driver releases interface 0 and
+leaves the last image up. It doesn't reboot the device or send the vendor close
+command, which blanks the screen.
+
+## Sources and what's left to test
+
+The [MythCool-Lite compatibility table](https://github.com/ZGMFX01A/MythCool-Lite/blob/342f8a330a5e8d83e229395fecf11bafd56a1158/README.md#硬件与驱动兼容性清单)
+lists the MC360 with this USB descriptor and 640×480 geometry, and its author
+reports testing it on Windows. That project's device code is private. The A021
+mode-2 packets and BGR565 framing come from
+[codehz/msdisplay](https://github.com/codehz/msdisplay/blob/34dd469d83c199a16832994755d2352495148a4e/bowei_aio/display.py).
+This transport was written from those protocol facts and includes none of that
+source, vendor binaries or firmware.
+
+Tests cover the descriptor and geometry checks, command byte order, malformed
+replies, screen-info rejection, BGR565 channel order and row stride, chunk and
+zero-length-packet order, failed acknowledgements and settings serialization.
+Someone with the hardware still has to check initialization, colors,
+orientation, static-image retention, brightness, screen-off, reconnect and
+suspend/resume on Linux.
+
+The B360 and B360-GT are left out on purpose: their profiles share material
+codes, and other MS resolutions aren't mapped to a retail model. The
+[vendor download page](https://valkyrie.com.cn/) also lists R, AL, ML and DX air
+coolers, but a family name or an advertised resolution isn't enough to know
+which protocol a model speaks, so none of them are listed.
diff --git a/docs/VALKYRIE-V360.md b/docs/VALKYRIE-V360.md
new file mode 100644
index 0000000..f94db80
--- /dev/null
+++ b/docs/VALKYRIE-V360.md
@@ -0,0 +1,69 @@
+# Valkyrie V360 LCD (320×240)
+
+The V360 screen sits behind a MacroSilicon MS9132 bridge (`345f:9132`), the same
+chip the VMAX 4.6" and the Jonsbo DS339 use. It has its own Valkyrie section on
+the Devices page with a separate streaming switch, and it reuses the VMAX UYVY
+renderer and device supervisor.
+
+## Identification
+
+The USB ID alone can't tell these panels apart, so the scan reads the first
+detailed timing descriptor of the panel EDID. 320×240 is a V360 and 376×960 is a
+DS339. Any other readable geometry stays on the VMAX path, as it did before the
+V360 existed, because no VMAX EDID has been captured yet. A panel whose EDID
+can't be read is skipped, and the scan status asks you to check USB/HID
+permissions.
+
+The V360 driver pairs the HID control interface with the video interface by USB
+serial, and rechecks the geometry before it initializes the panel. The VMAX
+driver skips any HID interface whose EDID says V360 or DS339, so a VMAX entry
+never initializes the wrong screen.
+
+Earlier releases added a V360 as a VMAX 4.6", or as a DS339 when the EDID was
+unreadable. A scan that confirms the V360 replaces that entry and keeps its
+profile, enabled state, brightness, rotation and hotkeys.
+
+The `345f:9132` udev rules cover both the USB and hidraw nodes. The deb, RPM and
+AUR packages re-trigger udev for already-connected V360 and MC360 screens, so
+they don't need a replug after install.
+
+## Transport
+
+HID feature reports go through `LinuxHidRawFeature`, which keeps the kernel HID
+driver bound. Each ioctl buffer holds report ID 0 plus eight payload bytes.
+
+After a V360-specific sequence of 77 commands, the driver sends complete UYVY
+frames to interface 3, bulk OUT endpoint `0x04`. A frame is 153616 bytes:
+`FF 00 00 00 00 14 00 F0`, then 153600 bytes of UYVY pixels, then
+`FF C0 00 00 00 00 00 00`.
+
+Scan-out starts after the first complete frame, with `B5 F5 07` (read),
+`B6 F5 07 00`, `A6 05 01 00` and `B5 C5 55` (read). After that the driver polls
+`B5 00 32` every 30 frames. Static profiles are resent, and a brightness,
+rotation or profile change invalidates the cached conversion.
+
+Brightness is software dimming. JPEG quality doesn't apply to raw UYVY, so the
+panel options hide it. The driver sends no pump, fan or RGB commands.
+
+## Testing
+
+[EsiksonX](https://github.com/EsiksonX) tested this on a V360 under Fedora 44
+with Linux 7.2. Discovery picked the V360 and left the Jonsbo path alone. A
+profile with text, a live clock and CPU temperature streamed at about 9 FPS
+against a 10 FPS target, with correct colors and orientation. Brightness and
+180° rotation changed live while streaming. Stopping and restarting the device
+task reacquired both interfaces, and a clean shutdown released them. Suspend and
+resume have not been tested on the V360.
+
+Tests pin the 77-command sequence with a digest of every padded payload and
+read flag, compared byte for byte with LCD Fusion. They also cover the EDID
+routing, the post-frame command order, settings round-trips, the separate family
+switches and the migration of stale entries. No other Valkyrie model on this USB
+ID has been verified. The [MC360](VALKYRIE-MC360.md) uses a different bulk
+transport.
+
+## Attribution
+
+The initialization sequence is adapted from
+[LCD Fusion](https://github.com/itsmylife44/LcdFusion), MIT © 2026 itsmylife44.
+The full notice is in `ValkyrieV360Protocol.cs` and `LICENSES.md`.
diff --git a/packaging/debian/postinst b/packaging/debian/postinst
index c52f3a8..ecdf138 100644
--- a/packaging/debian/postinst
+++ b/packaging/debian/postinst
@@ -6,5 +6,19 @@ if [[ "${1:-}" == configure ]]; then
fi
if [[ -S /run/udev/control ]] && command -v udevadm >/dev/null 2>&1; 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
fi
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..276201f
--- /dev/null
+++ b/src/InfoPanel.App/Services/PanelDiscoveryReconciler.cs
@@ -0,0 +1,98 @@
+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 entries earlier releases
+ // created for it: a DS339 when its EDID was unreadable, otherwise a VMAX 4.6".
+ var isV360 = found.Model == VmaxPanelModel.ValkyrieV360;
+ var stale = isV360
+ ? settings.JonsboPanelDevices.FirstOrDefault(d => d.Model == JonsboPanelModel.DS339 && Same(d.DeviceId, found.DeviceId))
+ : null;
+ var staleVmax = isV360 && !string.IsNullOrEmpty(found.LegacyDeviceId)
+ ? settings.VmaxPanelDevices.FirstOrDefault(d => d.Model == VmaxPanelModel.Vmax46Inch && Same(d.DeviceId, found.LegacyDeviceId))
+ : 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 = staleVmax?.ProfileGuid ?? stale?.ProfileGuid ?? Guid.Empty,
+ Enabled = staleVmax?.Enabled ?? stale?.Enabled ?? false,
+ Brightness = staleVmax?.Brightness ?? stale?.Brightness ?? 99,
+ Rotation = staleVmax?.Rotation ?? default,
+ };
+ 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 (staleVmax != null)
+ {
+ // The V360 is now keyed by serial, so hotkeys move to the new ID as well.
+ settings.VmaxPanelDevices.Remove(staleVmax);
+ foreach (var binding in settings.HotkeyBindings.Where(b => b.DeviceType == "Vmax" && Same(b.DeviceId, staleVmax.DeviceId)))
+ {
+ binding.DeviceType = "Valkyrie";
+ binding.DeviceId = found.DeviceId;
+ }
+ if (settings.VmaxPanelDevices.Count == 0) settings.VmaxPanelMultiDeviceMode = 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..ded5de9 100644
--- a/src/InfoPanel.Devices.VmaxPanel/VmaxPanelHelper.cs
+++ b/src/InfoPanel.Devices.VmaxPanel/VmaxPanelHelper.cs
@@ -15,6 +15,9 @@ public class VmaxPanelDiscoveryInfo
public string DevicePath { get; set; } = "";
public int VendorId { get; set; }
public int ProductId { get; set; }
+ public string SerialNumber { get; set; } = "";
+ /// The ID earlier releases saved for this device, which added every MS9132 as a VMAX 4.6".
+ public string LegacyDeviceId { get; set; } = "";
public VmaxPanelModel Model { get; set; }
public VmaxPanelModelInfo? ModelInfo { get; set; }
}
@@ -23,7 +26,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 +34,7 @@ public static List ScanDevices()
{
try
{
- devices.AddRange(ScanUsbDevices(vendorId, productId));
+ devices.AddRange(ScanUsbDevices(vendorId, productId, issues));
}
catch (Exception ex)
{
@@ -43,22 +46,79 @@ 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;
+
+ /// True when the EDID positively identifies a non-VMAX panel on the shared bridge.
+ public static bool IsOtherMs9132Panel((int Width, int Height)? size) =>
+ size is (376, 960) || ModelFromSize(size) is { Model: not VmaxPanelModel.Vmax46Inch };
+
+ ///
+ /// Model for a readable EDID. The VMAX 4.6" EDID has not been captured, so any
+ /// geometry that is neither the DS339 nor a known Valkyrie keeps the VMAX path
+ /// earlier releases always used.
+ ///
+ public static VmaxPanelModelInfo? ClassifyMs9132((int Width, int Height) size) =>
+ size is (376, 960) ? null // Jonsbo DS339
+ : ModelFromSize(size) ?? VmaxPanelModelDatabase.Models[VmaxPanelModel.Vmax46Inch];
+
+ private static string SafeSerial(HidDevice hid)
+ {
+ try { return hid.GetSerialNumber() ?? ""; } catch { return ""; }
+ }
+
+ 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();
+ var usbEntries = UsbDevice.AllDevices.Cast()
+ .Where(r => r.Vid == vendorId && r.Pid == productId).ToList();
- foreach (UsbRegistry deviceReg in UsbDevice.AllDevices)
+ foreach (UsbRegistry deviceReg in usbEntries)
{
- if (deviceReg.Vid != vendorId || deviceReg.Pid != productId)
+ 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 ?? "";
+ // Match both interfaces of the same physical panel before probing. Without
+ // a serial, a single attached bridge is unambiguous.
+ var hid = !string.IsNullOrEmpty(serial)
+ ? hidDevices.FirstOrDefault(h => SafeSerial(h) == serial)
+ : usbEntries.Count == 1 && hidDevices.Count == 1 ? hidDevices[0] : null;
+ var size = hid == null ? null : Ms9132PanelProbe.ReadSize(hid.DevicePath);
+ if (size == null)
+ {
+ issues?.Add("MS9132 panel could not be identified. Check USB/HID permissions, then scan again.");
+ continue;
+ }
+ var modelInfo = ClassifyMs9132(size.Value);
+ if (modelInfo == null) continue;
+ if (modelInfo.Model == VmaxPanelModel.ValkyrieV360 && string.IsNullOrEmpty(serial))
+ {
+ // The V360 driver pairs its HID and video interfaces by serial.
+ issues?.Add("Valkyrie V360 reports no USB serial, so it cannot be paired safely.");
continue;
+ }
deviceReg.DeviceProperties.TryGetValue("DeviceID", out var deviceIdValue);
deviceReg.DeviceProperties.TryGetValue("LocationInformation", out var locationValue);
var deviceId = deviceIdValue as string ?? deviceReg.DevicePath;
+ var legacyDeviceId = deviceId;
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 +127,8 @@ private static List ScanUsbDevices(int vendorId, int pro
DeviceId = deviceId,
DeviceLocation = location,
DevicePath = deviceReg.DevicePath,
+ SerialNumber = serial,
+ LegacyDeviceId = legacyDeviceId,
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..1ea557d 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;
+ }
+
+ 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);
+ }
- device._hidRaw = InitializeStreamingMode();
+ 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;
+ 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)
@@ -207,6 +255,16 @@ private static LinuxHidRawFeature InitializeStreamingMode()
LinuxHidRawFeature? raw = null;
try
{
+ // The bridge's VID/PID is shared: never run the VMAX init on a panel
+ // whose EDID says it is a V360 or DS339 (e.g. a stale VMAX entry).
+ var size = Ms9132PanelProbe.ReadSize(hidDevice.DevicePath);
+ if (VmaxPanelHelper.IsOtherMs9132Panel(size))
+ {
+ Logger.Information("VmaxUsbDevice: Skipping {Path}, EDID {W}x{H} is not a VMAX panel",
+ hidDevice.DevicePath, size!.Value.Width, size.Value.Height);
+ continue;
+ }
+
Logger.Information("VmaxUsbDevice: Trying HID init via {Path}", hidDevice.DevicePath);
raw = LinuxHidRawFeature.Open(hidDevice.DevicePath);
@@ -302,15 +360,18 @@ 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(FrameSuffix, 0, _frameBuffer, FramePrefix.Length + pixelDataLength, FrameSuffix.Length);
+ Buffer.BlockCopy(prefix, 0, _frameBuffer, 0, prefix.Length);
+ Buffer.BlockCopy(FrameSuffix, 0, _frameBuffer, prefix.Length + pixelDataLength, FrameSuffix.Length);
}
- Buffer.BlockCopy(pixelData, 0, _frameBuffer, FramePrefix.Length, pixelDataLength);
+ Buffer.BlockCopy(pixelData, 0, _frameBuffer, prefix.Length, pixelDataLength);
var offset = 0;
while (offset < _frameBuffer.Length)
@@ -325,8 +386,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 +408,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 +464,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..5a9208c
--- /dev/null
+++ b/tests/InfoPanel.App.Tests/ValkyrieDiscoveryTests.cs
@@ -0,0 +1,168 @@
+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);
+ }
+
+ [Theory]
+ [InlineData(320, 240, VmaxPanelModel.ValkyrieV360)]
+ [InlineData(320, 960, VmaxPanelModel.Vmax46Inch)]
+ [InlineData(960, 320, VmaxPanelModel.Vmax46Inch)] // VMAX EDID not yet captured: keep the old path
+ [InlineData(376, 960, VmaxPanelModel.Unknown)]
+ public void ReadableEdidKeepsVmaxAsTheFallback(int width, int height, VmaxPanelModel expected)
+ {
+ Assert.Equal(expected, VmaxPanelHelper.ClassifyMs9132((width, height))?.Model ?? VmaxPanelModel.Unknown);
+ }
+
+ [Fact]
+ public void VmaxInitSkipsOnlyPositivelyIdentifiedOtherPanels()
+ {
+ Assert.True(VmaxPanelHelper.IsOtherMs9132Panel((320, 240)));
+ Assert.True(VmaxPanelHelper.IsOtherMs9132Panel((376, 960)));
+ Assert.False(VmaxPanelHelper.IsOtherMs9132Panel((320, 960)));
+ Assert.False(VmaxPanelHelper.IsOtherMs9132Panel(null));
+ }
+
+ [Fact]
+ public void ConfirmedV360ReplacesTheVmaxEntryEarlierReleasesCreated()
+ {
+ var profile = Guid.NewGuid();
+ var settings = new Settings { VmaxPanelMultiDeviceMode = true };
+ settings.VmaxPanelDevices.Add(new VmaxPanelDevice
+ {
+ DeviceId = "/dev/bus/usb/001/005", Model = VmaxPanelModel.Vmax46Inch,
+ ProfileGuid = profile, Enabled = true, Brightness = 40, Rotation = LCD_ROTATION.Rotate180FlipNone,
+ });
+ settings.HotkeyBindings.Add(new HotkeyBinding { DeviceId = "/dev/bus/usb/001/005", DeviceType = "Vmax", ProfileGuid = profile });
+ var found = V360();
+ found.LegacyDeviceId = "/dev/bus/usb/001/005";
+ Assert.Equal((1, 0), PanelDiscoveryReconciler.Apply(settings, [], [found]));
+ Assert.Empty(settings.VmaxPanelDevices);
+ Assert.False(settings.VmaxPanelMultiDeviceMode);
+ var device = Assert.Single(settings.ValkyriePanelDevices);
+ Assert.Equal((profile, true, 40, LCD_ROTATION.Rotate180FlipNone),
+ (device.ProfileGuid, device.Enabled, device.Brightness, device.Rotation));
+ Assert.True(settings.ValkyriePanelMultiDeviceMode);
+ Assert.Equal(("Valkyrie", DeviceId), (settings.HotkeyBindings[0].DeviceType, settings.HotkeyBindings[0].DeviceId));
+ }
+
+ [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);
+ }
+}