From 132d83ed2218c86f7d4a472c628b8df31a4427ab Mon Sep 17 00:00:00 2001 From: Marco Massarotto Date: Thu, 25 May 2023 12:38:01 +0100 Subject: [PATCH] Improve support for NZXT Kraken V3 devices and merge KrakenX and KrakenZ hardware support (#1078) * Improve support for NZXT Kraken V3 devices and merge KrakenX and KrakenZ hardware support * add support for new kraken elite 2023 * Add support for kraken 2023 (non-elite) * removed ContinuousRead as it's too eager and conflicted with other application connecting to the device (to manage lcd or leds for example) * handle ObjectDisposedException and cleanup code * Update KrakenV3.cs --------- Co-authored-by: PhyxionNL <7643972+PhyxionNL@users.noreply.github.com> --- .../Hardware/Controller/Nzxt/KrakenV3.cs | 248 ++++++++++++++++++ .../Hardware/Controller/Nzxt/KrakenX3.cs | 164 ------------ .../Hardware/Controller/Nzxt/KrakenZ.cs | 218 --------------- .../Hardware/Controller/Nzxt/NzxtGroup.cs | 25 +- 4 files changed, 260 insertions(+), 395 deletions(-) create mode 100644 LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenV3.cs delete mode 100644 LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenX3.cs delete mode 100644 LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenZ.cs diff --git a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenV3.cs b/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenV3.cs new file mode 100644 index 0000000..a43e8dd --- /dev/null +++ b/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenV3.cs @@ -0,0 +1,248 @@ +using System; +using HidSharp; + +namespace LibreHardwareMonitor.Hardware.Controller.Nzxt; + +/** + * Support for the KrakenZ devices from NZXT + */ +internal sealed class KrakenV3 : Hardware +{ + private static readonly byte[] _getFirmwareInfo = { 0x10, 0x01 }; + private static readonly byte[] _setPumpSpeedHeader = { 0x72, 0x01, 0x00, 0x00 }; + private static readonly byte[] _setFanSpeedHeader = { 0x72, 0x02, 0x00, 0x00 }; + private static readonly byte[] _status_req = { 0x74, 0x01 }; + private static readonly byte[] _setPumpTarget = new byte[64]; + private static readonly byte[] _setFanTarget = new byte[64]; + + private readonly Sensor _pump; + private readonly Sensor _pumpRpm; + private readonly Sensor _fan; + private readonly Sensor _fanRpm; + private readonly byte[] _rawData = new byte[64]; + private readonly HidStream _stream; + private readonly Sensor _temperature; + private readonly bool _fanControl; + private readonly string _supportedFirmware; + private volatile bool _controllingFans; + private volatile bool _controllingPump; + + private static void FillTargetArray(byte[] targetArray, byte value) + { + for (byte i = 4; i < targetArray.Length; i++) + targetArray[i] = value; + } + + static KrakenV3() + { + // Init pump control target array + Array.Copy(_setPumpSpeedHeader, 0, _setPumpTarget, 0, 4); + // Fill target array with a pump duty of 60% + FillTargetArray(_setPumpTarget, 60); + + // Init fan control target array + Array.Copy(_setFanSpeedHeader, 0, _setFanTarget, 0, 4); + // Fill target array with a fan duty of 40% + FillTargetArray(_setFanTarget, 40); + } + + public KrakenV3(HidDevice dev, ISettings settings) : base("Nzxt Kraken Z", new Identifier("nzxt", "krakenz", dev.GetSerialNumber().TrimStart('0')), settings) + { + if (dev.ProductID == 0x3008) + { + Name = "NZXT Kraken Z3"; + _fanControl = true; + _supportedFirmware = "5.7.0"; + + } + else if (dev.ProductID == 0x300C) + { + Name = "NZXT Kraken Elite"; + _fanControl = true; + _supportedFirmware = "1.2.4"; + } + else if (dev.ProductID == 0x300E) + { + Name = "NZXT Kraken"; + _fanControl = true; + _supportedFirmware = "1.2.4"; // Firmware version to be confirmed + } + else + { + Name = "NZXT Kraken X3"; + _fanControl = false; + _supportedFirmware = "2.1.0"; + } + + if (dev.TryOpen(out _stream)) + { + _stream.ReadTimeout = 5000; + + _stream.Write(_getFirmwareInfo); + do + { + _stream.Read(_rawData); + if (_rawData[0] == 0x11 && _rawData[1] == 0x01) + { + FirmwareVersion = $"{_rawData[0x11]}.{_rawData[0x12]}.{_rawData[0x13]}"; + } + } + while (FirmwareVersion == null); + + // Liquid temperature + _temperature = new Sensor("Liquid", 0, SensorType.Temperature, this, Array.Empty(), settings); + ActivateSensor(_temperature); + + // Pump Control + _pump = new Sensor("Pump Control", 0, SensorType.Control, this, Array.Empty(), settings); + Control pumpControl = new(_pump, settings, 20, 100); + _pump.Control = pumpControl; + pumpControl.ControlModeChanged += PumpSoftwareControlValueChanged; + pumpControl.SoftwareControlValueChanged += PumpSoftwareControlValueChanged; + PumpSoftwareControlValueChanged(pumpControl); + ActivateSensor(_pump); + + // Pump RPM + _pumpRpm = new Sensor("Pump", 0, SensorType.Fan, this, Array.Empty(), settings); + ActivateSensor(_pumpRpm); + + if (_fanControl) + { + // Fan Control + _fan = new Sensor("Fans Control", 1, SensorType.Control, this, Array.Empty(), settings); + Control fanControl = new(_fan, settings, 20, 100); + _fan.Control = fanControl; + fanControl.ControlModeChanged += FanSoftwareControlValueChanged; + fanControl.SoftwareControlValueChanged += FanSoftwareControlValueChanged; + FanSoftwareControlValueChanged(fanControl); + ActivateSensor(_fan); + + // Fan RPM + _fanRpm = new Sensor("Fans", 1, SensorType.Fan, this, Array.Empty(), settings); + ActivateSensor(_fanRpm); + } + } + } + + public string FirmwareVersion { get; } + + public override HardwareType HardwareType => HardwareType.Cooler; + + public string Status => FirmwareVersion != _supportedFirmware ? $"Status: Untested Firmware Version {FirmwareVersion}! Please consider Updating to Version {_supportedFirmware}" : "Status: OK"; + + private void PumpSoftwareControlValueChanged(Control control) + { + try + { + if (control.ControlMode == ControlMode.Software) + { + float value = control.SoftwareValue; + + FillTargetArray(_setPumpTarget, (byte)(value > 100 ? 100 : value < 0 ? 0 : value)); + + _controllingPump = true; + _stream.Write(_setPumpTarget); + _pump.Value = value; + } + else if (control.ControlMode == ControlMode.Default) + { + // There isn't a "default" mode with this pump, but a safe setting is 60% + FillTargetArray(_setPumpTarget, 60); + _stream.Write(_setPumpTarget); + } + } + catch (ObjectDisposedException) + { + // Could be unplugged, or the app is stopping... + return; + } + } + + private void FanSoftwareControlValueChanged(Control control) + { + try + { + if (control.ControlMode == ControlMode.Software) + { + float value = control.SoftwareValue; + FillTargetArray(_setFanTarget, (byte)(value > 100 ? 100 : value < 0 ? 0 : value)); + + _controllingFans = true; + _stream.Write(_setFanTarget); + _fan.Value = value; + } + else if (control.ControlMode == ControlMode.Default) + { + // There isn't a "default" mode with this fan, but a safe setting is 40% + FillTargetArray(_setFanTarget, 40); + _stream.Write(_setFanTarget); + } + } + catch (ObjectDisposedException) + { + // Could be unplugged, or the app is stopping... + return; + } + } + + public override void Close() + { + base.Close(); + _stream?.Close(); + } + + public override void Update() + { + try + { + _stream.Write(_status_req); + do + { + _stream.Read(_rawData); + } while (_rawData[0] != 0x75 || _rawData[1] != 0x1); + + _temperature.Value = _rawData[15] + (_rawData[16] / 10.0f); + _pumpRpm.Value = (_rawData[18] << 8) | _rawData[17]; + + // The following logic makes sure the pump is set to the controlling value. This pump sometimes sets itself to 0% when instructed to a value. + if (!_controllingPump) + { + _pump.Value = _rawData[19]; + } + else if (_pump.Value != _rawData[19]) + { + float value = _pump.Value.GetValueOrDefault(); + FillTargetArray(_setPumpTarget, (byte)value); + _stream.Write(_setPumpTarget); + } + else + { + _controllingPump = false; + } + + if (_fanControl) + { + _fanRpm.Value = (_rawData[24] << 8) | _rawData[23]; + if (!_controllingFans) + { + _fan.Value = _rawData[25]; + } + else if (_fan.Value != _rawData[25]) + { + float value = _fan.Value.GetValueOrDefault(); + FillTargetArray(_setFanTarget, (byte)value); + _stream.Write(_setFanTarget); + } + else + { + _controllingFans = false; + } + } + } + catch (ObjectDisposedException) + { + // Could be unplugged, or the app is stopping... + return; + } + } +} diff --git a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenX3.cs b/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenX3.cs deleted file mode 100644 index 8b5e32e..0000000 --- a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenX3.cs +++ /dev/null @@ -1,164 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -// Copyright (C) LibreHardwareMonitor and Contributors. -// All Rights Reserved. - -using System; -using System.Linq; -using System.Threading; -using HidSharp; - -namespace LibreHardwareMonitor.Hardware.Controller.Nzxt; - -/** - * Support for the Kraken X3 devices from NZXT - */ -internal sealed class KrakenX3 : Hardware -{ - // Some fixed messages to send to the pump for basic monitoring and control - private static readonly byte[] _getFirmwareInfo = { 0x10, 0x01 }; - private static readonly byte[] _initialize1 = { 0x70, 0x02, 0x01, 0xb8, 0x0b }; - private static readonly byte[] _initialize2 = { 0x70, 0x01 }; - private static readonly byte[][] _setPumpTargetMap = new byte[101][]; // Sacrifice memory to speed this up with a lookup instead of a copy operation - - private readonly Sensor _pump; - private readonly Sensor _pumpRpm; - private readonly byte[] _rawData = new byte[64]; - private readonly HidStream _stream; - private readonly Sensor _temperature; - - private volatile bool _controlling; - - static KrakenX3() - { - byte[] setPumpSpeedHeader = { 0x72, 0x01, 0x00, 0x00 }; - - for (byte speed = 0; speed < _setPumpTargetMap.Length; speed++) - _setPumpTargetMap[speed] = setPumpSpeedHeader.Concat(Enumerable.Repeat(speed, 40).Concat(new byte[20])).ToArray(); - } - - public KrakenX3(HidDevice dev, ISettings settings) : base("Nzxt Kraken X3", new Identifier("nzxt", "krakenx3", dev.GetSerialNumber().TrimStart('0')), settings) - { - if (dev.TryOpen(out _stream)) - { - _stream.ReadTimeout = 5000; // The NZXT device returns with data that we need periodically without writing... - _stream.Write(_initialize1); - _stream.Write(_initialize2); - - _stream.Write(_getFirmwareInfo); - do - { - _stream.Read(_rawData); - if (_rawData[0] == 0x11 && _rawData[1] == 0x01) - { - FirmwareVersion = $"{_rawData[0x11]}.{_rawData[0x12]}.{_rawData[0x13]}"; - } - } - while (FirmwareVersion == null); - - Name = "Nzxt Kraken X3"; - - _pump = new Sensor("Pump Control", 0, SensorType.Control, this, Array.Empty(), settings); - Control pumpControl = new(_pump, settings, 0, 100); - _pump.Control = pumpControl; - pumpControl.ControlModeChanged += SoftwareControlValueChanged; - pumpControl.SoftwareControlValueChanged += SoftwareControlValueChanged; - SoftwareControlValueChanged(pumpControl); - ActivateSensor(_pump); - - _pumpRpm = new Sensor("Pump", 0, SensorType.Fan, this, Array.Empty(), settings); - ActivateSensor(_pumpRpm); - - _temperature = new Sensor("Internal Water", 0, SensorType.Temperature, this, Array.Empty(), settings); - ActivateSensor(_temperature); - - ThreadPool.UnsafeQueueUserWorkItem(ContinuousRead, _rawData); - } - } - - public string FirmwareVersion { get; } - - public override HardwareType HardwareType => HardwareType.Cooler; - - public string Status => FirmwareVersion != "2.1.0" ? $"Status: Untested Firmware Version {FirmwareVersion}! Please consider Updating to Version 2.1.0" : "Status: OK"; - - private void SoftwareControlValueChanged(Control control) - { - if (control.ControlMode == ControlMode.Software) - { - float value = control.SoftwareValue; - byte pumpSpeedIndex = (byte)(value > 100 ? 100 : value < 0 ? 0 : value); // Clamp the value, anything out of range will fail - - _controlling = true; - _stream.Write(_setPumpTargetMap[pumpSpeedIndex]); - _pump.Value = value; - } - else if (control.ControlMode == ControlMode.Default) - { - // There isn't a "default" mode with this pump, but a safe setting is 40% - _stream.Write(_setPumpTargetMap[40]); - } - } - - public override void Close() - { - base.Close(); - _stream?.Close(); - } - - private void ContinuousRead(object state) - { - byte[] buffer = new byte[_rawData.Length]; - while (_stream.CanRead) - { - try - { - _stream.Read(buffer); // This is a blocking call, will wait for bytes to become available - - lock (_rawData) - { - Array.Copy(buffer, _rawData, buffer.Length); - } - } - catch (TimeoutException) - { - // Don't care, just make sure the stream is still open - Thread.Sleep(500); - } - catch (ObjectDisposedException) - { - // Could be unplugged, or the app is stopping... - return; - } - } - } - - public override void Update() - { - // The NZXT Kraken X3 series sends updates periodically. We have to read it in a seperate thread, this call just reads that data. - lock (_rawData) - { - if (_rawData[0] == 0x75 && _rawData[1] == 0x02) - { - _temperature.Value = _rawData[15] + (_rawData[16] / 10.0f); - _pumpRpm.Value = (_rawData[18] << 8) | _rawData[17]; - - // The following logic makes sure the pump is set to the controlling value. This pump sometimes sets itself to 0% when instructed to a value. - if (!_controlling) - { - _pump.Value = _rawData[19]; - } - else if (_pump.Value != _rawData[19]) - { - float value = _pump.Value.GetValueOrDefault(); - byte pumpSpeedIndex = (byte)(value > 100 ? 100 : value < 0 ? 0 : value); // Clamp the value, anything out of range will fail - _stream.Write(_setPumpTargetMap[pumpSpeedIndex]); - } - else - { - _controlling = false; - } - } - } - } -} diff --git a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenZ.cs b/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenZ.cs deleted file mode 100644 index 2c039d8..0000000 --- a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/KrakenZ.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System; -using System.Threading; -using HidSharp; - -namespace LibreHardwareMonitor.Hardware.Controller.Nzxt; - -/** - * Support for the KrakenZ devices from NZXT - */ -internal sealed class KrakenZ : Hardware -{ - private static readonly byte[] _getFirmwareInfo = { 0x10, 0x01 }; - private static readonly byte[] _setPumpSpeedHeader = { 0x72, 0x01, 0x00, 0x00 }; - private static readonly byte[] _setFanSpeedHeader = { 0x72, 0x02, 0x00, 0x00 }; - private static readonly byte[] _status_req = { 0x74, 0x01 }; - private static readonly byte[] _setPumpTarget = new byte[64]; - private static readonly byte[] _setFanTarget = new byte[64]; - - private readonly Sensor _pump; - private readonly Sensor _pumpRpm; - private readonly Sensor _fan; - private readonly Sensor _fanRpm; - private readonly byte[] _rawData = new byte[64]; - private readonly HidStream _stream; - private readonly Sensor _temperature; - - private volatile bool _controlling; - - private static void FillTargetArray(byte[] targetArray, byte value) - { - for (byte i = 4; i < targetArray.Length; i++) - targetArray[i] = value; - } - - static KrakenZ() - { - // Init pump control target array - Array.Copy(_setPumpSpeedHeader, 0, _setPumpTarget, 0, 4); - // Fill target array with a pump duty of 60% - FillTargetArray(_setPumpTarget, 60); - - // Init fan control target array - Array.Copy(_setFanSpeedHeader, 0, _setFanTarget, 0, 4); - // Fill target array with a fan duty of 40% - FillTargetArray(_setFanTarget, 40); - } - - public KrakenZ(HidDevice dev, ISettings settings) : base("Nzxt Kraken Z", new Identifier("nzxt", "krakenz", dev.GetSerialNumber().TrimStart('0')), settings) - { - if (dev.TryOpen(out _stream)) - { - _stream.ReadTimeout = 5000; - - _stream.Write(_getFirmwareInfo); - do - { - _stream.Read(_rawData); - if (_rawData[0] == 0x11 && _rawData[1] == 0x01) - { - FirmwareVersion = $"{_rawData[0x11]}.{_rawData[0x12]}.{_rawData[0x13]}"; - } - } - while (FirmwareVersion == null); - - Name = "NZXT KrakenZ"; - - // Pump Control - _pump = new Sensor("Pump Control", 0, SensorType.Control, this, Array.Empty(), settings); - Control pumpControl = new(_pump, settings, 20, 100); - _pump.Control = pumpControl; - pumpControl.ControlModeChanged += PumpSoftwareControlValueChanged; - pumpControl.SoftwareControlValueChanged += PumpSoftwareControlValueChanged; - PumpSoftwareControlValueChanged(pumpControl); - ActivateSensor(_pump); - - // Pump RPM - _pumpRpm = new Sensor("Pump", 0, SensorType.Fan, this, Array.Empty(), settings); - ActivateSensor(_pumpRpm); - - // Fan Control - _fan = new Sensor("Fans Control", 1, SensorType.Control, this, Array.Empty(), settings); - Control fanControl = new(_fan, settings, 20, 100); - _fan.Control = fanControl; - fanControl.ControlModeChanged += FanSoftwareControlValueChanged; - fanControl.SoftwareControlValueChanged += FanSoftwareControlValueChanged; - FanSoftwareControlValueChanged(fanControl); - ActivateSensor(_fan); - - // Fan RPM - _fanRpm = new Sensor("Fans", 1, SensorType.Fan, this, Array.Empty(), settings); - ActivateSensor(_fanRpm); - - // Liquid temperature - _temperature = new Sensor("Liquid", 0, SensorType.Temperature, this, Array.Empty(), settings); - ActivateSensor(_temperature); - - ThreadPool.UnsafeQueueUserWorkItem(ContinuousRead, _rawData); - } - } - - public string FirmwareVersion { get; } - - public override HardwareType HardwareType => HardwareType.Cooler; - - public string Status => FirmwareVersion != "5.7.0" ? $"Status: Untested Firmware Version {FirmwareVersion}! Please consider Updating to Version 5.7.0" : "Status: OK"; - - private void PumpSoftwareControlValueChanged(Control control) - { - if (control.ControlMode == ControlMode.Software) - { - float value = control.SoftwareValue; - FillTargetArray(_setPumpTarget, (byte)value); - - _controlling = true; - _stream.Write(_setPumpTarget); - _pump.Value = value; - } - else if (control.ControlMode == ControlMode.Default) - { - // There isn't a "default" mode with this pump, but a safe setting is 60% - FillTargetArray(_setPumpTarget, 60); - _stream.Write(_setPumpTarget); - } - } - - private void FanSoftwareControlValueChanged(Control control) - { - if (control.ControlMode == ControlMode.Software) - { - float value = control.SoftwareValue; - FillTargetArray(_setFanTarget, (byte)value); - - _controlling = true; - _stream.Write(_setFanTarget); - _fan.Value = value; - } - else if (control.ControlMode == ControlMode.Default) - { - // There isn't a "default" mode with this fan, but a safe setting is 40% - FillTargetArray(_setFanTarget, 40); - _stream.Write(_setFanTarget); - } - } - - public override void Close() - { - base.Close(); - _stream?.Close(); - } - - private void ContinuousRead(object state) - { - byte[] buffer = new byte[_rawData.Length]; - while (_stream.CanRead) - { - try - { - _stream.Write(_status_req); // Request status - _stream.Read(buffer); // This is a blocking call, will wait for bytes to become available - - lock (_rawData) - { - Array.Copy(buffer, _rawData, buffer.Length); - } - } - catch (TimeoutException) - { - // Don't care, just make sure the stream is still open - Thread.Sleep(500); - } - catch (ObjectDisposedException) - { - // Could be unplugged, or the app is stopping... - return; - } - } - } - - public override void Update() - { - lock (_rawData) - { - if (_rawData[0] == 0x75 /*status response*/ && _rawData.Length >= 30) - { - _temperature.Value = _rawData[15] + (_rawData[16] / 10.0f); - _pumpRpm.Value = (_rawData[18] << 8) | _rawData[17]; - _fanRpm.Value = (_rawData[24] << 8) | _rawData[23]; - - // The following logic makes sure the pump is set to the controlling value. This pump sometimes sets itself to 0% when instructed to a value. - if (!_controlling) - { - _pump.Value = _rawData[19]; - _fan.Value = _rawData[25]; - } - else if (_pump.Value != _rawData[19] || _fan.Value != _rawData[25]) - { - if (_pump.Value != _rawData[19]) - { - float value = _pump.Value.GetValueOrDefault(); - FillTargetArray(_setPumpTarget, (byte)value); - _stream.Write(_setPumpTarget); - } - - if (_fan.Value != _rawData[25]) - { - float value = _fan.Value.GetValueOrDefault(); - FillTargetArray(_setFanTarget, (byte)value); - _stream.Write(_setFanTarget); - } - } - else - { - _controlling = false; - } - } - } - } -} diff --git a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/NzxtGroup.cs b/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/NzxtGroup.cs index 51de9f2..f8ffc05 100644 --- a/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/NzxtGroup.cs +++ b/LibreHardwareMonitorLib/Hardware/Controller/Nzxt/NzxtGroup.cs @@ -25,14 +25,20 @@ internal class NzxtGroup : IGroup switch (dev.ProductID) { - case 0x2007: - var device = new KrakenX3(dev, settings); + case 0x2007: // Kraken X3 original pid + case 0x2014: // Kraken X3 new pid + case 0x3008: // Kraken Z3 + case 0x300C: // Kraken 2023 elite + case 0x300E: // Kraken 2023 standard + // NZXT KrakenV3 Devices + var krakenV3 = new KrakenV3(dev, settings); _report.AppendLine($"Device name: {productName}"); - _report.AppendLine($"Firmware version: {device.FirmwareVersion}"); - _report.AppendLine($"{device.Status}"); + _report.AppendLine($"Firmware version: {krakenV3.FirmwareVersion}"); + _report.AppendLine($"{krakenV3.Status}"); _report.AppendLine(); - _hardware.Add(device); + _hardware.Add(krakenV3); break; + case 0x1711: var gridv3 = new GridV3(dev, settings); _report.AppendLine($"Device name: {productName}"); @@ -40,14 +46,7 @@ internal class NzxtGroup : IGroup _report.AppendLine(); _hardware.Add(gridv3); break; - case 0x3008: // NZXT KrakenZ Device - var krakenZ = new KrakenZ(dev, settings); - _report.AppendLine($"Device name: {productName}"); - _report.AppendLine($"Firmware version: {krakenZ.FirmwareVersion}"); - _report.AppendLine($"{krakenZ.Status}"); - _report.AppendLine(); - _hardware.Add(krakenZ); - break; + default: _report.AppendLine($"Unknown Hardware PID: {dev.ProductID} Name: {productName}");