diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..315c771 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (YALCY)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/YALCY/bin/Debug/net9.0/YALCY.dll", + "args": [], + "cwd": "${workspaceFolder}/YALCY", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Launch (YALCY.CLI)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/YALCY.CLI/bin/Debug/net9.0/YALCY.CLI.dll", + "args": [], + "cwd": "${workspaceFolder}/YALCY.CLI", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..b4d2f8f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,57 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/YALCY/YALCY.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "build-cli", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/YALCY.CLI/YALCY.CLI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/YALCY/YALCY.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/YALCY/YALCY.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/YALCY/Integrations/OpenRGB/OpenRgbTalker.cs b/YALCY/Integrations/OpenRGB/OpenRgbTalker.cs index b076727..24fbe07 100644 --- a/YALCY/Integrations/OpenRGB/OpenRgbTalker.cs +++ b/YALCY/Integrations/OpenRGB/OpenRgbTalker.cs @@ -14,16 +14,32 @@ namespace YALCY.Integrations.OpenRGB; +public class ZoneInfo +{ + public Device Device { get; set; } = null!; + public int ZoneIndex { get; set; } + public Zone Zone { get; set; } = null!; +} + public class OpenRgbTalker { private CancellationTokenSource cts = new(); private Task updateTask = Task.CompletedTask; + // Legacy device-based lists (kept for backward compatibility) public List OffList = new(); public List LightPodList = new(); public List StrobeList = new(); public List FoggerList = new(); public Dictionary LightPodStates = new Dictionary(); + + // New zone-based dictionaries + public Dictionary OffZones = new(); + public Dictionary LightPodZones = new(); + public Dictionary StrobeZones = new(); + public Dictionary FoggerZones = new(); + public Dictionary LightPodZoneStates = new Dictionary(); + private static string name = "YALCY"; private static bool autoConnect = false; // can't catch exceptions in constructor private static int timeoutMs = 1000; @@ -62,6 +78,26 @@ public async Task ConnectToOpenRgbServerAsync(string serverIp, ushort serverPort try { + // Clear all device lists to prevent duplicates on reconnection + OffList.Clear(); + LightPodList.Clear(); + StrobeList.Clear(); + FoggerList.Clear(); + LightPodStates.Clear(); + OffZones.Clear(); + LightPodZones.Clear(); + StrobeZones.Clear(); + FoggerZones.Clear(); + LightPodZoneStates.Clear(); + + // Clear visual lists in the UI synchronously + await Dispatcher.UIThread.InvokeAsync(() => + { + MainWindowViewModel.ClearOpenRgbVisualList(); + mainViewModel.DeviceCategories.Clear(); + mainViewModel.DevicesWithZones.Clear(); + }); + client = new OpenRgbClient(serverIp, serverPort, name, autoConnect, timeoutMs, protocolVersionNumber); // This really should be awaited since it waits for timeoutMs, however it isn't written that way. @@ -88,6 +124,7 @@ public async Task ConnectToOpenRgbServerAsync(string serverIp, ushort serverPort OffList.Add(device); OpenRgbDeviceInserted?.Invoke(device); mainViewModel.DeviceCategories.Add(new DeviceCategory(device, 0, mainViewModel)); + mainViewModel.DevicesWithZones.Add(new DeviceWithZones(device, mainViewModel)); } UsbDeviceMonitor.OnStageKitCommand += OnStageKitEvent; @@ -144,10 +181,22 @@ private void OnDeviceLisUpdate(object o, EventArgs e) var devices = client.GetAllControllerData(); OffList.Clear(); Dispatcher.UIThread.InvokeAsync(MainWindowViewModel.ClearOpenRgbVisualList); + + if (_mainViewModel != null) + { + Dispatcher.UIThread.InvokeAsync(() => _mainViewModel.ClearDevicesWithZones()); + } + foreach (var dev in devices) { OffList.Add(dev); OpenRgbDeviceInserted?.Invoke(dev); + + if (_mainViewModel != null) + { + Dispatcher.UIThread.InvokeAsync(() => + _mainViewModel.DevicesWithZones.Add(new DeviceWithZones(dev, _mainViewModel))); + } } } @@ -252,16 +301,28 @@ private void StartStrobeEffect(int speed, float bpm) { while (!cts.Token.IsCancellationRequested) { + // Support both legacy device-based and new zone-based strobes foreach (var device in StrobeList) { ToggleDeviceLeds(device, true); } + + foreach (var zoneInfo in StrobeZones.Values) + { + ToggleZoneLeds(zoneInfo, true); + } await Task.Delay(interval); + foreach (var device in StrobeList) { ToggleDeviceLeds(device, false); } + + foreach (var zoneInfo in StrobeZones.Values) + { + ToggleZoneLeds(zoneInfo, false); + } await Task.Delay(interval); } @@ -284,12 +345,14 @@ private void StartBreathingEffect() for (byte brightness = 0; brightness <= 255; brightness += 5) { SetDeviceBrightness(brightness); + SetZoneBrightness(brightness); await Task.Delay(30); } for (byte brightness = 255; brightness >= 0; brightness -= 5) { SetDeviceBrightness(brightness); + SetZoneBrightness(brightness); await Task.Delay(30); } } @@ -309,15 +372,27 @@ private void SetDeviceBrightness(byte brightness) client.UpdateLeds(device.Index, colors); } } + + private void SetZoneBrightness(byte brightness) + { + foreach (var zoneInfo in FoggerZones.Values) + { + var color = new Color(brightness, brightness, brightness); + var colors = Enumerable.Repeat(color, (int)zoneInfo.Zone.LedCount).ToArray(); + client.UpdateZoneLeds(zoneInfo.Device.Index, zoneInfo.ZoneIndex, colors); + } + } private void UpdateLightPodColor(byte parameter, Color color, int areaOffset) { - if (LightPodList.Count == 0) + if (LightPodList.Count == 0 && LightPodZones.Count == 0) { return; } const int numAreas = 32; + + // Legacy device-based lightpods foreach (var device in LightPodList) { // Adjust the number of LEDs per area, ensuring at least one LED per area @@ -345,6 +420,37 @@ private void UpdateLightPodColor(byte parameter, Color color, int areaOffset) // Update LEDs for this device client.UpdateLeds(device.Index, new Span(colors)); } + + // New zone-based lightpods + foreach (var kvp in LightPodZones) + { + var zoneInfo = kvp.Value; + var zoneKey = kvp.Key; + + var keysPerArea = Math.Max(1, zoneInfo.Zone.LedCount / numAreas); + var colors = LightPodZoneStates[zoneKey]; + + for (int area = areaOffset; area < areaOffset + 8; area++) + { + for (int key = 0; key < keysPerArea; key++) + { + var ledIndex = area * keysPerArea + key; + if (ledIndex >= zoneInfo.Zone.LedCount) continue; + + if ((parameter & (1 << (area - areaOffset))) != 0) + { + colors[ledIndex] = color; + } + else + { + colors[ledIndex] = new Color(0, 0, 0); + } + } + } + + // Update LEDs for this zone + client.UpdateZoneLeds(zoneInfo.Device.Index, zoneInfo.ZoneIndex, new Span(colors)); + } } @@ -354,6 +460,13 @@ private void ToggleDeviceLeds(Device device, bool turnOn) var colors = Enumerable.Repeat(color, device.Leds.Length).ToArray(); client.UpdateLeds(device.Index, colors); } + + private void ToggleZoneLeds(ZoneInfo zoneInfo, bool turnOn) + { + var color = turnOn ? new Color(255, 255, 255) : new Color(0, 0, 0); + var colors = Enumerable.Repeat(color, (int)zoneInfo.Zone.LedCount).ToArray(); + client.UpdateZoneLeds(zoneInfo.Device.Index, zoneInfo.ZoneIndex, colors); + } private int CalculateDelay(int noteValue, float bpm) { diff --git a/YALCY/Program.cs b/YALCY/Program.cs index 4f0b999..3ada7e3 100644 --- a/YALCY/Program.cs +++ b/YALCY/Program.cs @@ -18,6 +18,6 @@ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() - .LogToTrace() + .LogToTrace() .UseReactiveUI(); } diff --git a/YALCY/ViewModels/MainWindowViewModel.cs b/YALCY/ViewModels/MainWindowViewModel.cs index 8fb0268..04725f6 100644 --- a/YALCY/ViewModels/MainWindowViewModel.cs +++ b/YALCY/ViewModels/MainWindowViewModel.cs @@ -170,7 +170,7 @@ private void InitializeEnableSettings() SettingsManager.OpenRgbEnabledSettingIsEnabled, "YALCY is talking OpenRGB!", "YALCY is NOT talking to OpenRGB!", - async (isEnabled) => OpenRgbTalker.EnableOpenRgbTalker(isEnabled, OpenRgbServerIp, OpenRgbServerPort), + async (isEnabled) => await OpenRgbTalker.EnableOpenRgbTalker(isEnabled, OpenRgbServerIp ?? "127.0.0.1", OpenRgbServerPort), "Enable or disable output to a OpenRGB client" ); } @@ -184,7 +184,7 @@ private void InitializeCommands() { RegisterHueBridgeCommand = ReactiveCommand.CreateFromTask(() => HueTalker.RegisterHueBridgeAsync(HueBridgeIp)); ConnectToOpenRgbServerCommand = ReactiveCommand.CreateFromTask(() => - OpenRgbTalker.ConnectToOpenRgbServerAsync(OpenRgbServerIp, OpenRgbServerPort)); + OpenRgbTalker.ConnectToOpenRgbServerAsync(OpenRgbServerIp ?? "127.0.0.1", OpenRgbServerPort)); } private async void ShutdownRequested(object? sender, ShutdownRequestedEventArgs e) @@ -201,7 +201,7 @@ public async Task ShutdownAsync() SettingsManager.SaveSettings(this); // Turn off the OpenRGB talker - await OpenRgbTalker.EnableOpenRgbTalker(false, OpenRgbServerIp, OpenRgbServerPort, this); + await OpenRgbTalker.EnableOpenRgbTalker(false, OpenRgbServerIp ?? "127.0.0.1", OpenRgbServerPort, this); // Turn off the RB3E Talker Rb3ETalker.EnableRb3eTalker(false); @@ -278,6 +278,140 @@ public EnableSetting(string label, bool isEnabled, string onString, string offSt } } +public class DeviceWithZones : ReactiveObject +{ + public Device Device { get; set; } + public ObservableCollection Zones { get; set; } + + public DeviceWithZones(Device device, MainWindowViewModel? viewModel = null) + { + Device = device; + Zones = new ObservableCollection(); + + // Create a zone category for each zone in the device + for (int i = 0; i < device.Zones.Length; i++) + { + var zone = device.Zones[i]; + Zones.Add(new DeviceZoneCategory(device, zone, i, 0, viewModel)); + } + } +} + +public class DeviceZoneCategory : ReactiveObject, INotifyPropertyChanged +{ + public new event PropertyChangedEventHandler? PropertyChanged; + public Device Device { get; set; } + public Zone Zone { get; set; } + public int ZoneIndex { get; set; } + private int _category; + private MainWindowViewModel? _viewModel; + + public int Category + { + get => _category; + set + { + RemoveFromCategoryList(_category); + _category = value; + UpdateDeviceCategoryList(_category); + OnPropertyChanged(nameof(Category)); + } + } + + public DeviceZoneCategory(Device device, Zone zone, int zoneIndex, int initialCategory, MainWindowViewModel? viewModel = null) + { + Device = device; + Zone = zone; + ZoneIndex = zoneIndex; + _category = initialCategory; + _viewModel = viewModel; + } + + public void SetViewModel(MainWindowViewModel viewModel) + { + _viewModel = viewModel; + } + + private void RemoveFromCategoryList(int category) + { + if (_viewModel == null) return; + + var key = GetZoneKey(); + + switch (category) + { + case 0: + _viewModel.OpenRgbTalker.OffZones.Remove(key); + break; + + case 1: + _viewModel.OpenRgbTalker.LightPodZones.Remove(key); + lock (_viewModel.OpenRgbTalker.LightPodZoneStates) + { + _viewModel.OpenRgbTalker.LightPodZoneStates.Remove(key); + } + break; + + case 2: + _viewModel.OpenRgbTalker.StrobeZones.Remove(key); + break; + + case 3: + _viewModel.OpenRgbTalker.FoggerZones.Remove(key); + break; + } + } + + private void UpdateDeviceCategoryList(int category) + { + if (_viewModel == null) return; + + var key = GetZoneKey(); + var zoneInfo = new ZoneInfo { Device = Device, ZoneIndex = ZoneIndex, Zone = Zone }; + + switch (category) + { + case 0: + _viewModel.OpenRgbTalker.OffZones[key] = zoneInfo; + break; + + case 1: + _viewModel.OpenRgbTalker.LightPodZones[key] = zoneInfo; + lock (_viewModel.OpenRgbTalker.LightPodZoneStates) + { + if (!_viewModel.OpenRgbTalker.LightPodZoneStates.ContainsKey(key)) + { + // Initialize the light pod state for this zone + int ledCount = (int)Zone.LedCount; + _viewModel.OpenRgbTalker.LightPodZoneStates[key] = new Color[ledCount]; + } + } + break; + + case 2: + _viewModel.OpenRgbTalker.StrobeZones[key] = zoneInfo; + break; + + case 3: + _viewModel.OpenRgbTalker.FoggerZones[key] = zoneInfo; + break; + } + } + + private string GetZoneKey() + { + return $"{Device.Index}_{ZoneIndex}"; + } + + public string GetZoneKeyProperty => GetZoneKey(); + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + +// Keep the old DeviceCategory for backward compatibility public class DeviceCategory : ReactiveObject, INotifyPropertyChanged { public new event PropertyChangedEventHandler? PropertyChanged; diff --git a/YALCY/ViewModels/OpenRgbSettings.cs b/YALCY/ViewModels/OpenRgbSettings.cs index c2849d1..0f112eb 100644 --- a/YALCY/ViewModels/OpenRgbSettings.cs +++ b/YALCY/ViewModels/OpenRgbSettings.cs @@ -10,10 +10,11 @@ public partial class MainWindowViewModel { private string? _openRgbServerIp; private ushort _openRgbServerPort; - private string _openRgbStatus; + private string _openRgbStatus = null!; private static ObservableCollection? OpenRgbDevices { get; set; } - public ICommand ConnectToOpenRgbServerCommand { set; get; } - public ObservableCollection DeviceCategories { get; set; } + public ICommand ConnectToOpenRgbServerCommand { set; get; } = null!; + public ObservableCollection DeviceCategories { get; set; } = null!; + public ObservableCollection DevicesWithZones { get; set; } = null!; public string OpenRgbStatus { @@ -55,6 +56,7 @@ private void InitializeOpenRgbCollections() { OpenRgbDevices = new ObservableCollection(); DeviceCategories = new ObservableCollection(); + DevicesWithZones = new ObservableCollection(); } public static void OnOpenRgbDeviceInserted(Device device) @@ -71,4 +73,9 @@ public static void ClearOpenRgbVisualList() { OpenRgbDevices?.Clear(); } + + public void ClearDevicesWithZones() + { + DevicesWithZones?.Clear(); + } } diff --git a/YALCY/Views/Tabs/OpenRgbTabView.axaml b/YALCY/Views/Tabs/OpenRgbTabView.axaml index 7c1462f..78bdb18 100644 --- a/YALCY/Views/Tabs/OpenRgbTabView.axaml +++ b/YALCY/Views/Tabs/OpenRgbTabView.axaml @@ -3,75 +3,458 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:YALCY.ViewModels" + xmlns:tabs="clr-namespace:YALCY.Views.Tabs" x:Class="YALCY.Views.Tabs.OpenRgbTabView" x:DataType="vm:MainWindowViewModel" mc:Ignorable="d"> - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + - - - - -